refactor: fix all P0/P1/P2 bugs and architecture issues
Bug fixes (from bugs/ directory): - Fix cross-module DB queries in 9 modules (homework, grades, parent, diagnostic, elective, proctoring, notifications, scheduling, classes) by routing through data-access functions - Fix shared/lib <-> auth circular dependency via new session.ts module - Fix divide-by-zero guard in grades data-access - Fix audit export data truncation (paginated fetch for full datasets) - Fix missing transactions in homework grading and elective lottery - Fix missing revalidatePath in course-plans actions - Fix frontend permission checks using requirePermission instead of requireAuth - Fix dashboard role routing using session.user.roles - Fix student auth pattern (migrate getDemoStudentUser to users module) - Fix ActionState return type handling in components Code quality fixes: - Remove 60+ as type assertions (replace with type guards) - Remove non-null assertions (use optional chaining or explicit checks) - Convert dynamic imports to static imports (grades, diagnostic) - Add React.cache() wrapping for read functions - Parallelize independent queries with Promise.all - Add explicit return types to 30+ arrow functions - Replace any with unknown + type guards - Fix import type for type-only imports - Add Zod validation schemas for classes and diagnostic modules - Extract duplicate code (normalizeRoleName, normalizeBcryptHash, logger IP extraction) - Add console.error to silent catch blocks - Fix permission naming consistency (exam:proctor_read -> exam:proctor:read) Architecture doc sync: - Update 004_architecture_impact_map.md and 005_architecture_data.json - Update management-modules-audit.md for P0-7 cross-module fix Moved deleted proctoring event route to deletes/ folder.
This commit is contained in:
258
bugs/001_first_login_onboarding.md
Normal file
258
bugs/001_first_login_onboarding.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# 首次登录引导(Onboarding)重大问题讨论
|
||||
|
||||
> 创建日期:2026-06-18
|
||||
> 状态:**讨论中,待决策**
|
||||
> 关联架构图:`docs/architecture/004_architecture_impact_map.md` §2.1 shared 层 / §3 已知问题 P2-4
|
||||
> 关联代码:
|
||||
> - [src/shared/components/onboarding-gate.tsx](file:///e:/Desktop/CICD/src/shared/components/onboarding-gate.tsx)(312 行)
|
||||
> - [src/app/api/onboarding/status/route.ts](file:///e:/Desktop/CICD/src/app/api/onboarding/status/route.ts)
|
||||
> - [src/app/api/onboarding/complete/route.ts](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts)
|
||||
> - [src/app/layout.tsx](file:///e:/Desktop/CICD/src/app/layout.tsx#L41)(全局挂载点)
|
||||
|
||||
---
|
||||
|
||||
## 一、背景与定位
|
||||
|
||||
按项目规则"先图后码",先从架构影响地图定位 Onboarding 相关节点:
|
||||
|
||||
- **shared 层**:`components/onboarding-gate.tsx`(312 行)已被架构图标记为 ⚠️ P2-4「业务逻辑泄漏到 shared」
|
||||
- **app 层**:`/api/onboarding/status`、`/api/onboarding/complete` 两条路由
|
||||
- **数据层**:`users.onboardedAt`([src/shared/db/schema.ts:41](file:///e:/Desktop/CICD/src/shared/db/schema.ts#L41))
|
||||
- **被调用模块**:`modules/classes/data-access.ts` 的 `enrollStudentByInvitationCode`
|
||||
|
||||
当前 Onboarding 是一个**全局 Dialog**:在 `app/layout.tsx` 第 41 行无条件挂载 `<OnboardingGate />`,组件内通过 `useEffect` 拉取 `/api/onboarding/status`,若 `required === true` 则弹出不可关闭的 4 步 Dialog。
|
||||
|
||||
---
|
||||
|
||||
## 二、现状代码盘点
|
||||
|
||||
### 2.1 组件层(onboarding-gate.tsx)
|
||||
|
||||
| 步骤 | 标题 | 采集字段 | 备注 |
|
||||
|------|------|----------|------|
|
||||
| Step 0 | 角色选择 | role(student/teacher/parent) | admin 只读展示;其他角色用户可下拉**自选** |
|
||||
| Step 1 | 通用信息 | name / phone / address | 仅校验非空 |
|
||||
| Step 2 | 角色信息 | classCodes(学生/教师)、teacherSubjects(教师) | 可跳过;家长显示"暂不需要配置" |
|
||||
| Step 3 | 完成 | — | 调 `/api/onboarding/complete` 后跳 `/dashboard` |
|
||||
|
||||
**角色推断逻辑**(第 90-94 行)——用权限点反推角色:
|
||||
|
||||
```ts
|
||||
const isAdmin = permissions.includes(Permissions.SETTINGS_ADMIN)
|
||||
const isTeacher = permissions.includes(Permissions.EXAM_CREATE)
|
||||
const isStudent = permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)
|
||||
const isParent = !permissions.includes(Permissions.EXAM_CREATE) && !permissions.includes(Permissions.HOMEWORK_SUBMIT) && permissions.includes(Permissions.EXAM_READ)
|
||||
```
|
||||
|
||||
### 2.2 API 层
|
||||
|
||||
- `GET /api/onboarding/status`:查 `users.onboardedAt` 是否为空 + 查 `usersToRoles` 推断角色
|
||||
- `POST /api/onboarding/complete`:更新 users 表 → 写 usersToRoles → 学生调 `enrollStudentByInvitationCode` → 教师直接 insert `classSubjectTeachers` → 写 `onboardedAt`
|
||||
|
||||
---
|
||||
|
||||
## 三、重大问题清单(按风险分级)
|
||||
|
||||
### 🔴 P0 级:安全/合规/越权
|
||||
|
||||
#### P0-1 用户可自选角色(严重越权)
|
||||
- **位置**:[onboarding-gate.tsx:192-201](file:///e:/Desktop/CICD/src/shared/components/onboarding-gate.tsx#L192-L201)
|
||||
- **问题**:Step 0 允许任意登录用户从下拉框选择 `student / teacher / parent` 角色;`complete/route.ts:32-35` 直接信任前端 `body.role` 并写入 `usersToRoles`。
|
||||
- **后果**:任何注册用户可自封为 teacher,从而获得 `exam:create`、`homework:grade` 等权限;可自封为 parent 查看他人成绩。**这是 K12 教务系统的合规红线**。
|
||||
- **违反规则**:项目规则「Server Action 必须使用 `requirePermission()`」、K12 行业铁律「角色由管理员预分配」。
|
||||
|
||||
#### P0-2 教师可绑定任意班级+科目
|
||||
- **位置**:[complete/route.ts:95-130](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts#L95-L130)
|
||||
- **问题**:教师通过 `classCodes`(6 位邀请码)可把自己写入任意班级的 `classSubjectTeachers`,且 `teacherSubjects` 由前端任意提交,服务端仅做"名称存在性"校验,不校验该教师是否被管理员分配到该班。
|
||||
- **后果**:教师可越权查看任意班级学生名单、成绩;可篡改他人班级的任课关系。
|
||||
- **违反规则**:项目规则「modules 之间通过对方 data-access 通信,不直接查询对方 DB 表」——此处 app 层 API 直接 insert `classSubjectTeachers`。
|
||||
|
||||
#### P0-3 无权限校验、无 Zod、无事务
|
||||
- **位置**:[complete/route.ts](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts) 整文件
|
||||
- **问题**:
|
||||
- 仅检查 `auth()` 登录态,**未调用 `requirePermission()`**
|
||||
- 用 `String(body.role ?? "")` 手动解析,**无 Zod**(架构图 005 声称"validation: Zod schema"与实际不符)
|
||||
- 5 次独立 DB 写入(update users / insert usersToRoles / enrollStudent / insert classSubjectTeachers / update onboardedAt)**无 `db.transaction()`**
|
||||
- 运行时 `db.insert(roles).values({ name: role })` 创建角色记录(第 66-68 行)——角色应在 seed 时创建,运行时创建属异常路径
|
||||
- **后果**:中途失败导致数据不一致(如已绑定角色但 `onboardedAt` 仍为 null,用户被反复弹窗);越权写入。
|
||||
|
||||
### 🟠 P1 级:架构违规
|
||||
|
||||
#### P1-1 shared 层反向承载领域逻辑
|
||||
- **位置**:[onboarding-gate.tsx](file:///e:/Desktop/CICD/src/shared/components/onboarding-gate.tsx) 整文件
|
||||
- **问题**:组件位于 `shared/components/`,但包含角色判断、班级代码、教师科目配置等强领域逻辑,并通过 fetch 调用业务 API。
|
||||
- **违反规则**:项目规则「shared 不得反向依赖 @/auth、@/proxy 或任何 modules/*」「shared 是被依赖方」。
|
||||
- **架构图标记**:004 文档 §2.1 已标记 P2-4。
|
||||
|
||||
#### P1-2 app 层 API 直接跨模块写表
|
||||
- **位置**:[complete/route.ts:6](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts#L6)
|
||||
- **问题**:`app/api/onboarding/complete/route.ts` 直接 import 并写入 `classes`、`classSubjectTeachers`、`subjects` 表,绕过 `modules/classes` 的 data-access 与权限校验。
|
||||
- **违反规则**:项目规则「app 只能调用 modules 的 Server Actions 和 data-access,不直接访问 DB」「modules 之间通过对方 data-access 通信」。
|
||||
|
||||
#### P1-3 角色推断双源不一致
|
||||
- **位置**:[status/route.ts:29-41](file:///e:/Desktop/CICD/src/app/api/onboarding/status/route.ts#L29-L41) vs [onboarding-gate.tsx:90-94](file:///e:/Desktop/CICD/src/shared/components/onboarding-gate.tsx#L90-L94)
|
||||
- **问题**:status API 用 `roles.name` 推断角色(含 `grade_head/teaching_head → teacher` 归一化),组件又用权限点重新推断,两套逻辑可能不一致(如年级组长既有 EXAM_CREATE 又有其他权限,组件推断可能错位)。
|
||||
|
||||
### 🟡 P2 级:用户体验与可访问性
|
||||
|
||||
#### P2-1 全局 Dialog 模式缺陷
|
||||
- **问题**:
|
||||
- Dialog 不可关闭(`canClose = !required`),用户被强制锁定
|
||||
- 刷新页面丢失步骤状态(step 重置为 0)
|
||||
- 无独立 URL,无法分享/书签
|
||||
- 首屏无骨架屏,`useEffect` 拉取 status 期间会闪烁
|
||||
- 依赖 `session?.user?.name` 触发重复请求
|
||||
- **对比**:业界主流(Auth.js 官方、Clerk、Vercel 模板)均采用独立路由 `/onboarding` + middleware 重定向。
|
||||
|
||||
#### P2-2 表单校验粗糙
|
||||
- **问题**:电话仅校验非空(无手机号格式校验);姓名无长度限制;地址无长度限制;班级代码无格式预校验。
|
||||
|
||||
#### P2-3 国际化与可访问性
|
||||
- **问题**:中英文混合("Role"、"Select role" 英文,其余中文);Dialog 缺少 `aria-describedby`;进度条无 `aria-valuenow`;表单无 `required` 标记。
|
||||
|
||||
#### P2-4 进度条与步骤不一致
|
||||
- **问题**:admin 跳过 Step 2,但进度条仍渲染 4 段,视觉上 Step 2 永远亮起,造成困惑。
|
||||
|
||||
---
|
||||
|
||||
## 四、业界大仓(Monorepo)解决方案引用
|
||||
|
||||
### 4.1 Auth.js v5 官方推荐
|
||||
|
||||
- **状态标记**:`users.onboardedAt` 字段 + `jwt`/`session` 回调注入 session;完成时调 `update()` 刷新 token。
|
||||
- **强制方式**:**middleware 重定向**到独立 `/onboarding` 路由,而非客户端 Dialog。
|
||||
- 在 `middleware.ts` 用 `auth()` 读取 session,若 `user.onboardedAt` 为空且路径不在白名单(`/login`、`/api/auth`、`/onboarding`、静态资源),则 `NextResponse.redirect(new URL('/onboarding', req.url))`。
|
||||
- **结论**:客户端 Dialog 仅适合"非阻塞的偏好补全"(如头像、通知偏好);强制 onboarding 应等同未登录处理。
|
||||
|
||||
### 4.2 商业方案(Clerk / Supabase / Auth0)共性
|
||||
|
||||
三段式:**metadata 标记 + 强制重定向独立路由 + 服务端 Action 校验**。
|
||||
|
||||
- **角色等敏感字段放服务端可写的 metadata**(Clerk `privateMetadata` / Auth0 `appMetadata` / Supabase RLS-protected `profiles.role`),**禁止前端自写**。
|
||||
- onboarding 完成回调必须由服务端 Action 写入 metadata,前端不能直接改。
|
||||
- 未完成 onboarding 时 middleware/Action 层强制重定向。
|
||||
|
||||
### 4.3 shadcn/ui 生态
|
||||
|
||||
- 官方无内置 Stepper,但 `examples/forms` 与 `blocks` 范式明确:**独立路由页面 + `<Form>`(react-hook-form + zod)+ 父组件持 step state**。
|
||||
- 每步独立 zod schema 做渐进式校验,最后一步汇总写入。
|
||||
- 官方 `blocks/login-04` 等登录块均采用独立路由页面,而非全局 Dialog。
|
||||
|
||||
### 4.4 企业级 K12 教务系统(PowerSchool / Veracross / 国内智慧校园)
|
||||
|
||||
**铁律:角色由管理员预分配,用户不可自选。**
|
||||
|
||||
| 角色 | 首次登录采集字段 | 角色来源 |
|
||||
|------|------------------|----------|
|
||||
| 学生 | 学号(预分配不可改)、姓名、性别、出生日期、家长联系方式、紧急联系人 | 管理员批量导入 |
|
||||
| 教师 | 工号(预分配)、姓名、所教科目、任教班级、办公室、联系电话、学历资质 | 教务处预分配 |
|
||||
| 家长 | 与学生关系、学生学号(通过学校发放的 **Access ID + Access Password** 绑定)、本人姓名、电话、邮箱 | 学校发放凭证,家长绑定子女 |
|
||||
| 管理员 | 工号、姓名、职务、管理范围 | 学校 IT 创建 |
|
||||
|
||||
**原因**:
|
||||
1. **合规**:K12 数据受《个人信息保护法》《未成年人保护法》约束,学生身份必须由学校权威确认。
|
||||
2. **安全**:允许自选教师角色 = 任何人可创建考试、查看全班成绩。
|
||||
3. **数据一致性**:班级、学号、任课关系是教务核心数据,必须由教务处维护。
|
||||
|
||||
### 4.5 Monorepo(turborepo / nx)惯例
|
||||
|
||||
- **turborepo 官方模板**:跨模块"流程型"功能(onboarding、setup-wizard)作为**独立 module**,而非塞进 shared。
|
||||
- **nx feature-shell 模式**:onboarding 作为 `feature-onboarding` library,依赖 `data-access-user`、`data-access-class`。
|
||||
- **Vercel 自家项目**:`app/(app)/onboarding/[[...step]]/page.tsx` 路由组 + `modules/onboarding/` 模块。
|
||||
|
||||
---
|
||||
|
||||
## 五、重构方案建议(待讨论)
|
||||
|
||||
### 5.1 目标架构
|
||||
|
||||
```
|
||||
app/
|
||||
├─ (auth)/login/ # 登录页(middleware 白名单)
|
||||
├─ (onboarding)/onboarding/ # 新增独立路由
|
||||
│ └─ page.tsx # 服务端组件,读取 session.onboarded 决定渲染
|
||||
└─ middleware.ts # 新增/增强:未 onboarded 时重定向
|
||||
|
||||
modules/onboarding/ # 新建模块
|
||||
├─ actions.ts # completeOnboardingAction(Server Action + requirePermission)
|
||||
├─ data-access.ts # 仅操作 users.onboardedAt
|
||||
├─ schema.ts # Zod:name/phone/address/classCodes
|
||||
├─ types.ts
|
||||
└─ components/
|
||||
├─ OnboardingStepper.tsx # 客户端 stepper 容器
|
||||
├─ RoleConfirmStep.tsx # 只读展示管理员分配的角色
|
||||
├─ ProfileStep.tsx # 姓名/电话/住址
|
||||
└─ BindingStep.tsx # 学生:确认班级;教师:确认任课;家长:绑定子女
|
||||
|
||||
shared/
|
||||
└─ components/onboarding-gate.tsx # 删除
|
||||
```
|
||||
|
||||
### 5.2 关键改动点
|
||||
|
||||
1. **删除 `shared/components/onboarding-gate.tsx`**,从 `app/layout.tsx` 移除挂载。
|
||||
2. **新建 `modules/onboarding/`**,承载所有领域逻辑。
|
||||
3. **新建 `app/(onboarding)/onboarding/page.tsx`** 独立路由。
|
||||
4. **增强 `middleware.ts`**:读取 session.onboarded,未完成且非白名单路径 → 重定向到 `/onboarding`。
|
||||
5. **Auth.js 回调**:在 `jwt`/`session` 回调注入 `onboardedAt`,供 middleware 读取。
|
||||
6. **删除 `app/api/onboarding/*/route.ts`**,改为 `modules/onboarding/actions.ts` 的 Server Action。
|
||||
7. **角色只读化**:Step 0 改为"角色确认"——只读展示 `usersToRoles` 中的角色,用户不可改。
|
||||
8. **班级绑定改造**:
|
||||
- 学生:仅"确认"管理员预分配的班级,或输入邀请码(服务端校验有效性 + 用途)
|
||||
- 教师:仅"确认"管理员预分配的任课关系,**移除自填班级代码**
|
||||
- 家长:输入"子女学号 + 绑定码"绑定子女(参考 PowerSchool Access ID 模式)
|
||||
9. **事务化**:`completeOnboardingAction` 用 `db.transaction()` 包裹所有写入。
|
||||
10. **Zod 校验**:定义 `onboardingSchema`,phone 用 `z.string().regex(/^1\d{10}$/)`。
|
||||
|
||||
### 5.3 迁移兼容
|
||||
|
||||
- 已 onboarded 用户(`onboardedAt` 非空)不受影响,middleware 直接放行。
|
||||
- 未 onboarded 用户下次登录会被重定向到 `/onboarding`(而非弹 Dialog)。
|
||||
- 无需数据迁移,`users.onboardedAt` 字段保留。
|
||||
|
||||
---
|
||||
|
||||
## 六、待决策的开放问题
|
||||
|
||||
请就以下问题给出决策,以便进入实施阶段:
|
||||
|
||||
### Q1:角色分配策略
|
||||
- **方案 A**(推荐,符合 K12 铁律):onboarding 中角色完全只读,由管理员通过后台预分配;用户无法在 onboarding 中改变角色。
|
||||
- **方案 B**:保留角色选择,但服务端校验"用户已有该角色"才允许选择(即只能从已有角色中选一个主角色)。
|
||||
- **方案 C**:暂不改动角色选择,仅修复其他问题。
|
||||
|
||||
### Q2:教师任课关系绑定
|
||||
- **方案 A**(推荐):onboarding 中教师**仅确认**管理员预分配的任课关系,不自填班级代码。
|
||||
- **方案 B**:保留自填邀请码,但服务端强校验邀请码用途(teacher-assign)、有效期、使用次数。
|
||||
- **方案 C**:完全移除 onboarding 中的班级绑定,统一由管理员后台处理。
|
||||
|
||||
### Q3:家长绑定子女方式
|
||||
- **方案 A**(推荐,PowerSchool 模式):家长输入"子女学号 + 学校发放的 6 位绑定码"。
|
||||
- **方案 B**:家长输入"子女学号 + 子女生日"作为验证。
|
||||
- **方案 C**:暂不实现家长绑定,由管理员后台预绑定。
|
||||
|
||||
### Q4:onboarding 路由形态
|
||||
- **方案 A**(推荐):单页 `/onboarding` + 客户端 stepper(步骤状态用 query param 持久化)。
|
||||
- **方案 B**:嵌套路由 `/onboarding/role`、`/onboarding/profile`、`/onboarding/binding`(每步独立 Server Action)。
|
||||
- **方案 C**:保留全局 Dialog,仅修复安全与架构问题。
|
||||
|
||||
### Q5:实施范围
|
||||
- **方案 A**:一次性完成 P0 + P1 + P2 全部整改。
|
||||
- **方案 B**:先做 P0(安全/越权)+ P1(架构),P2(UX)后续迭代。
|
||||
- **方案 C**:仅做 P0 紧急修复,P1/P2 列入 backlog。
|
||||
|
||||
---
|
||||
|
||||
## 七、附录:问题与代码位置速查
|
||||
|
||||
| 问题 | 代码位置 | 风险 |
|
||||
|------|----------|------|
|
||||
| 用户自选角色 | [onboarding-gate.tsx:192-201](file:///e:/Desktop/CICD/src/shared/components/onboarding-gate.tsx#L192-L201) | 🔴 P0 |
|
||||
| 信任前端 role 写入 | [complete/route.ts:32-35](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts#L32-L35) | 🔴 P0 |
|
||||
| 教师绑任意班级 | [complete/route.ts:95-130](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts#L95-L130) | 🔴 P0 |
|
||||
| 无权限校验/Zod/事务 | [complete/route.ts](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts) 整文件 | 🔴 P0 |
|
||||
| shared 反向承载领域逻辑 | [onboarding-gate.tsx](file:///e:/Desktop/CICD/src/shared/components/onboarding-gate.tsx) 整文件 | 🟠 P1 |
|
||||
| app 层跨模块写表 | [complete/route.ts:6](file:///e:/Desktop/CICD/src/app/api/onboarding/complete/route.ts#L6) | 🟠 P1 |
|
||||
| 角色推断双源不一致 | [status/route.ts:29-41](file:///e:/Desktop/CICD/src/app/api/onboarding/status/route.ts#L29-L41) vs [onboarding-gate.tsx:90-94](file:///e:/Desktop/CICD/src/shared/components/onboarding-gate.tsx#L90-L94) | 🟠 P1 |
|
||||
| 全局 Dialog 缺陷 | [app/layout.tsx:41](file:///e:/Desktop/CICD/src/app/layout.tsx#L41) | 🟡 P2 |
|
||||
| 表单校验粗糙 | [onboarding-gate.tsx:88](file:///e:/Desktop/CICD/src/shared/components/onboarding-gate.tsx#L88) | 🟡 P2 |
|
||||
548
bugs/admin_bug.md
Normal file
548
bugs/admin_bug.md
Normal file
@@ -0,0 +1,548 @@
|
||||
# Admin 前端文件规范核查报告
|
||||
|
||||
> 核查范围:`src/app/(dashboard)/admin/` 下全部 26 个 `page.tsx` 文件
|
||||
> 核查依据:
|
||||
> - `.trae/rules/project_rules.md`(项目规则)
|
||||
> - `docs/standards/coding-standards.md`(编码规范 v1.0)
|
||||
> - `docs/architecture/004_architecture_impact_map.md`(架构影响地图)
|
||||
> - React / Next.js 16 最佳实践
|
||||
> - Web 界面设计规范(WCAG 2.2 AA)
|
||||
> 核查日期:2026-06-18
|
||||
|
||||
---
|
||||
|
||||
## 一、核查概览
|
||||
|
||||
| 维度 | 文件数 | 通过 | 待改进 |
|
||||
|------|--------|------|--------|
|
||||
| 架构分层 | 26 | 24 | 2 |
|
||||
| TypeScript 规范 | 26 | 4 | 22 |
|
||||
| 安全与权限 | 26 | 3 | 23 |
|
||||
| UI 一致性与设计令牌 | 26 | 18 | 8 |
|
||||
| 错误与加载边界 | 26 | 0 | 26 |
|
||||
| 代码复用(DRY) | 26 | 0 | 26 |
|
||||
|
||||
**结论**:整体架构清晰、服务端组件使用规范、并行数据获取到位,但在**返回类型标注、权限校验一致性、加载/错误边界、代码复用、UI 文案一致性**方面存在系统性问题,需统一整改。
|
||||
|
||||
---
|
||||
|
||||
## 二、问题清单(按严重程度排序)
|
||||
|
||||
### P0 严重问题(必须立即修复)
|
||||
|
||||
#### P0-1 全部 26 个页面缺少 `error.tsx` 与 `loading.tsx`
|
||||
|
||||
**违反规范**:
|
||||
- 编码规范 §2.3:「每个路由段应提供 `loading.tsx`(骨架屏)和 `error.tsx`(错误边界)」
|
||||
- 编码规范 §2.4:「每个路由段都必须提供 `error.tsx`,不得出现未捕获异常导致白屏」
|
||||
- 编码规范 §2.4:「`loading.tsx` 必须提供骨架屏或最小可感知的加载状态,不得使用全局 spin 遮罩」
|
||||
|
||||
**现状**:`src/app/(dashboard)/admin/` 及其所有子路由(`dashboard/`、`announcements/`、`school/*`、`audit-logs/*`、`scheduling/*`、`course-plans/*`、`elective/*`、`attendance/`、`files/`、`users/import/`)均**未提供** `loading.tsx` 和 `error.tsx`。
|
||||
|
||||
对比:`teacher/`、`student/` 路由组在关键页面已提供 `loading.tsx`(如 `teacher/exams/all/loading.tsx`、`student/dashboard/loading.tsx`),admin 路由组完全缺失。
|
||||
|
||||
**影响**:
|
||||
- 数据获取失败时整页白屏,用户体验差
|
||||
- 无加载态感知,用户误以为页面卡死
|
||||
- 不符合 Next.js 16 App Router 最佳实践(Suspense 流式渲染)
|
||||
|
||||
**修复建议**:
|
||||
1. 在 `src/app/(dashboard)/admin/` 根目录新增 `error.tsx`(具名导出,客户端组件,含重试按钮)
|
||||
2. 在 `src/app/(dashboard)/admin/` 根目录新增 `loading.tsx`(骨架屏,匹配各页面布局)
|
||||
3. 对数据量大的页面(`audit-logs/*`、`school/grades/insights`、`attendance`)单独提供 `loading.tsx`
|
||||
4. 对动态路由(`[id]/page.tsx`)单独提供 `error.tsx` 处理 `notFound` 以外的异常
|
||||
|
||||
---
|
||||
|
||||
#### P0-2 `attendance/page.tsx` 缺少权限校验
|
||||
|
||||
**文件**:[src/app/(dashboard)/admin/attendance/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/attendance/page.tsx)
|
||||
|
||||
**违反规范**:
|
||||
- 项目规则:「Server Action 必须使用 `requirePermission()` 进行权限校验」
|
||||
- 编码规范 §8.3:「权限校验:Server Action 必须使用 `requirePermission()`」
|
||||
|
||||
**现状**:第 26 行仅调用 `getAuthContext()` 获取上下文,**未调用 `requirePermission()`** 验证用户是否有考勤查看权限。
|
||||
|
||||
```tsx
|
||||
// 当前代码(第 26 行)
|
||||
const ctx = await getAuthContext()
|
||||
```
|
||||
|
||||
**对比**:同类 admin 页面均做了权限校验:
|
||||
- `audit-logs/page.tsx` 第 22 行:`await requirePermission(Permissions.AUDIT_LOG_READ)`
|
||||
- `audit-logs/login-logs/page.tsx` 第 22 行:`await requirePermission(Permissions.AUDIT_LOG_READ)`
|
||||
- `audit-logs/data-changes/page.tsx` 第 26 行:`await requirePermission(Permissions.AUDIT_LOG_READ)`
|
||||
- `files/page.tsx` 第 12 行:`await requirePermission(Permissions.FILE_READ)`
|
||||
|
||||
**影响**:越权风险——无考勤查看权限的用户可直接访问 `/admin/attendance` 查看全校考勤数据。
|
||||
|
||||
**修复建议**:在 `getAuthContext()` 前增加权限校验:
|
||||
```tsx
|
||||
await requirePermission(Permissions.ATTENDANCE_READ)
|
||||
const ctx = await getAuthContext()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### P1 重要问题(应尽快修复)
|
||||
|
||||
#### P1-1 全部 26 个页面组件缺少返回类型标注
|
||||
|
||||
**违反规范**:
|
||||
- 编码规范 §4.2:「函数返回值必须显式标注,特别是 `Promise<T>`」
|
||||
- 项目规则:「函数返回值必须显式标注」
|
||||
|
||||
**现状**:所有 `page.tsx` 的默认导出函数均未标注返回类型,例如:
|
||||
|
||||
```tsx
|
||||
// dashboard/page.tsx
|
||||
export default async function AdminDashboardPage() { // ❌ 缺少 : Promise<JSX.Element>
|
||||
const data = await getAdminDashboardData()
|
||||
return <AdminDashboardView data={data} />
|
||||
}
|
||||
```
|
||||
|
||||
**影响**:26 个文件全部不合规,类型推导依赖 TS 隐式推断,不利于代码审查与维护。
|
||||
|
||||
**修复建议**:统一补充返回类型:
|
||||
```tsx
|
||||
export default async function AdminDashboardPage(): Promise<JSX.Element> {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
涉及文件:admin 目录下全部 26 个 `page.tsx`。
|
||||
|
||||
---
|
||||
|
||||
#### P1-2 `getParam` 工具函数在 27 个文件中重复定义
|
||||
|
||||
**违反规范**:
|
||||
- 编码规范 §一:「单一职责」「工具函数 ≤ 40 行」
|
||||
- DRY 原则
|
||||
|
||||
**现状**:以下 admin 文件各自重复定义了相同的 `getParam` / `SearchParams` 类型与函数:
|
||||
|
||||
| 文件 | 行号 |
|
||||
|------|------|
|
||||
| `announcements/page.tsx` | 8-13 |
|
||||
| `audit-logs/page.tsx` | 10-15 |
|
||||
| `audit-logs/login-logs/page.tsx` | 10-15 |
|
||||
| `audit-logs/data-changes/page.tsx` | 14-19 |
|
||||
| `scheduling/changes/page.tsx` | 16-21 |
|
||||
| `course-plans/page.tsx` | 7-12 |
|
||||
| `elective/page.tsx` | 7-12 |
|
||||
| `attendance/page.tsx` | 13-18 |
|
||||
| `school/grades/insights/page.tsx` | 15-22 |
|
||||
|
||||
全项目共 27 个文件重复(含 teacher / student / management 路由组)。
|
||||
|
||||
**影响**:维护成本高,任何一处逻辑变更需同步修改 27 处。
|
||||
|
||||
**修复建议**:
|
||||
1. 在 `src/shared/lib/utils.ts` 新增共享工具:
|
||||
```tsx
|
||||
export type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
export function getSearchParam(params: SearchParams, key: string): string | undefined {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
```
|
||||
2. 全部页面改为 `import { getSearchParam, type SearchParams } from "@/shared/lib/utils"`
|
||||
3. 同步更新架构文档 004 / 005
|
||||
|
||||
---
|
||||
|
||||
#### P1-3 多个页面使用 `as` 类型断言违反 TypeScript 规范
|
||||
|
||||
**违反规范**:
|
||||
- 编码规范 §4.2:「不使用 `as` 断言,除非从 `unknown` 强制转换或在测试中(需注释原因)」
|
||||
- 项目规则:「禁止 `as` 断言(除非从 `unknown` 转换或测试中,需注释原因)」
|
||||
|
||||
**现状**:以下文件使用 `as` 进行类型断言而非类型守卫:
|
||||
|
||||
| 文件 | 行号 | 问题代码 |
|
||||
|------|------|---------|
|
||||
| `audit-logs/page.tsx` | 28 | `(getParam(params, "status") as AuditLogStatus \| undefined)` |
|
||||
| `audit-logs/login-logs/page.tsx` | 26-27 | `as LoginLogAction \| undefined`、`as LoginLogStatus \| undefined` |
|
||||
| `audit-logs/data-changes/page.tsx` | 31 | `as DataChangeAction \| undefined` |
|
||||
| `attendance/page.tsx` | 39 | `as "present" \| "absent" \| "late" \| "early_leave" \| "excused"` |
|
||||
|
||||
**对比(正确示例)**:以下文件已使用类型守卫,应作为模板推广:
|
||||
- `announcements/page.tsx` 第 15-16 行:`isValidStatus` 类型守卫
|
||||
- `scheduling/changes/page.tsx` 第 23-24 行:`isValidStatus` 类型守卫
|
||||
- `course-plans/page.tsx` 第 14-15 行:`isValidStatus` 类型守卫
|
||||
- `elective/page.tsx` 第 14-15 行:`isValidStatus` 类型守卫
|
||||
|
||||
**影响**:运行时无法捕获非法枚举值,类型安全被绕过。
|
||||
|
||||
**修复建议**:为每个枚举类型补充类型守卫,替换 `as` 断言:
|
||||
```tsx
|
||||
const isValidAuditLogStatus = (v?: string): v is AuditLogStatus =>
|
||||
v === "success" || v === "failure" || v === "pending"
|
||||
|
||||
const status = isValidAuditLogStatus(statusParam) ? statusParam : undefined
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### P1-4 UI 文案语言不统一(中英文混用)
|
||||
|
||||
**违反规范**:
|
||||
- 编码规范 §一:「可读性优先」
|
||||
- 项目定位为「Next_Edu K12 智慧教务系统」(中文用户)
|
||||
|
||||
**现状**:
|
||||
|
||||
| 文件 | 文案语言 |
|
||||
|------|---------|
|
||||
| `users/import/page.tsx` | 中文("批量导入用户"、"返回") |
|
||||
| `announcements/[id]/page.tsx` | 英文("Edit Announcement") |
|
||||
| `school/schools/page.tsx` | 英文("Schools"、"Manage schools...") |
|
||||
| `school/classes/page.tsx` | 英文("Classes"、"Manage classes...") |
|
||||
| `school/grades/page.tsx` | 英文("Grades"、"Manage grades...") |
|
||||
| `school/grades/insights/page.tsx` | 英文("Grade Insights"、"Filters") |
|
||||
| `school/academic-year/page.tsx` | 英文("Academic Year") |
|
||||
| `school/departments/page.tsx` | 英文("Departments") |
|
||||
| `audit-logs/page.tsx` | 英文("Audit Logs") |
|
||||
| `audit-logs/login-logs/page.tsx` | 英文("Login Logs") |
|
||||
| `audit-logs/data-changes/page.tsx` | 英文("Data Change Logs") |
|
||||
| `scheduling/auto/page.tsx` | 英文("Auto Schedule") |
|
||||
| `scheduling/changes/page.tsx` | 英文("Schedule Change Requests") |
|
||||
| `scheduling/rules/page.tsx` | 英文("Scheduling Rules") |
|
||||
| `course-plans/page.tsx` | 英文("Course Plans") |
|
||||
| `course-plans/create/page.tsx` | 英文("New Course Plan") |
|
||||
| `course-plans/[id]/edit/page.tsx` | 英文("Edit Course Plan") |
|
||||
| `elective/page.tsx` | 英文("Elective Courses") |
|
||||
| `elective/create/page.tsx` | 英文("New Elective Course") |
|
||||
| `elective/[id]/edit/page.tsx` | 英文("Edit Elective Course") |
|
||||
| `attendance/page.tsx` | 英文("Attendance Overview") |
|
||||
|
||||
**影响**:用户体验割裂,admin 区仅 `users/import` 为中文,其余全英文,与系统定位不符。
|
||||
|
||||
**修复建议**:统一为中文(与 `users/import/page.tsx` 保持一致),或引入 i18n 方案统一管理。建议优先统一为中文。
|
||||
|
||||
---
|
||||
|
||||
### P2 一般问题(建议修复)
|
||||
|
||||
#### P2-1 `school/grades/insights/page.tsx` 使用原生 `<select>` 而非共享组件
|
||||
|
||||
**文件**:[src/app/(dashboard)/admin/school/grades/insights/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/school/grades/insights/page.tsx#L57-L68)
|
||||
|
||||
**现状**:第 57-68 行使用原生 `<select>` 元素,而项目已提供 `@/shared/components/ui/select.tsx`(shadcn Select)。
|
||||
|
||||
```tsx
|
||||
<select
|
||||
name="gradeId"
|
||||
defaultValue={selected || "all"}
|
||||
className="h-10 w-full rounded-md border bg-background px-3 text-sm md:w-[360px]"
|
||||
>
|
||||
```
|
||||
|
||||
**影响**:
|
||||
- UI 风格与其他页面不一致(其他页面使用 shadcn Select)
|
||||
- 原生 `<select>` 样式难以跨浏览器统一
|
||||
- 可访问性较弱(缺少 ARIA 属性)
|
||||
|
||||
**修复建议**:替换为 `@/shared/components/ui/select.tsx` 的 `Select` / `SelectTrigger` / `SelectContent` / `SelectItem` 组合。
|
||||
|
||||
---
|
||||
|
||||
#### P2-2 `users/import/page.tsx` 使用原生 `<table>` 而非共享组件
|
||||
|
||||
**文件**:[src/app/(dashboard)/admin/users/import/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/users/import/page.tsx#L93-L128)
|
||||
|
||||
**现状**:第 93-128 行使用原生 `<table>` 元素手写表格,而项目已提供 `@/shared/components/ui/table.tsx`(shadcn Table)。
|
||||
|
||||
**影响**:与 `school/grades/insights/page.tsx` 等使用 shadcn Table 的页面风格不一致。
|
||||
|
||||
**修复建议**:替换为 `Table` / `TableHeader` / `TableBody` / `TableRow` / `TableHead` / `TableCell` 组合。
|
||||
|
||||
---
|
||||
|
||||
#### P2-3 Tailwind 任意值违规
|
||||
|
||||
**违反规范**:
|
||||
- 编码规范 §6.2:「禁止使用任意值(`w-[137px]`),除非有充分理由并注释说明」
|
||||
- 项目规则:「禁止使用任意值(`w-[137px]`),除非有充分理由并注释」
|
||||
|
||||
**现状**:
|
||||
|
||||
| 文件 | 行号 | 问题类名 |
|
||||
|------|------|---------|
|
||||
| `school/grades/insights/page.tsx` | 60 | `md:w-[360px]` |
|
||||
| `school/grades/insights/page.tsx` | 82, 89, 96 | `h-[360px]` |
|
||||
| `users/import/page.tsx` | 16 | `h-full flex-1 flex-col`(`flex-1` 合理,但整体布局类应复用) |
|
||||
|
||||
**修复建议**:
|
||||
- `md:w-[360px]` → 使用设计令牌宽度类(如 `md:w-72` 或 `md:w-80`)或在 globals.css 定义 `--filter-width` 变量
|
||||
- `h-[360px]` → 使用 `h-80`(320px)或 `h-96`(384px)等标准档位
|
||||
|
||||
---
|
||||
|
||||
#### P2-4 `users/import/page.tsx` 使用硬编码颜色
|
||||
|
||||
**违反规范**:
|
||||
- 编码规范 §6.3:「所有视觉设计决策(颜色、字号、间距)必须体现在设计令牌中,组件中不使用硬编码值」
|
||||
|
||||
**文件**:[src/app/(dashboard)/admin/users/import/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/users/import/page.tsx#L67)
|
||||
|
||||
**现状**:第 67 行使用 `text-amber-500` 硬编码颜色:
|
||||
```tsx
|
||||
<Info className="h-5 w-5 text-amber-500" />
|
||||
```
|
||||
|
||||
**修复建议**:使用设计令牌颜色,如 `text-warning`(若存在)或在 globals.css 定义 `--warning` 变量。如暂无 warning 令牌,可使用 `text-primary` 或 `text-muted-foreground` 保持一致。
|
||||
|
||||
---
|
||||
|
||||
#### P2-5 `school/grades/insights/page.tsx` 导入顺序违规
|
||||
|
||||
**违反规范**:
|
||||
- 编码规范 §4.3:「导入顺序:React → 第三方 → 内部绝对路径 → 相对路径 → 类型导入」
|
||||
- 项目规则引用的 ESLint `import/order` 规则
|
||||
|
||||
**文件**:[src/app/(dashboard)/admin/school/grades/insights/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/school/grades/insights/page.tsx#L1-L11)
|
||||
|
||||
**现状**:第 1-11 行导入顺序混乱,`lucide-react`(第三方库)被放在所有 `@/` 内部导入之后:
|
||||
```tsx
|
||||
import Link from "next/link" // next(外部)
|
||||
import { getGrades } from "@/modules/school/data-access" // 内部
|
||||
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Table, TableBody, ... } from "@/shared/components/ui/table"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { BarChart3 } from "lucide-react" // ❌ 第三方应在前
|
||||
```
|
||||
|
||||
**修复建议**:调整为 `next` → `lucide-react` → `@/` 内部导入,分组间空一行:
|
||||
```tsx
|
||||
import Link from "next/link"
|
||||
|
||||
import { BarChart3 } from "lucide-react"
|
||||
|
||||
import { getGrades } from "@/modules/school/data-access"
|
||||
// ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### P2-6 `course-plans/[id]/edit/page.tsx` 同模块重复导入
|
||||
|
||||
**文件**:[src/app/(dashboard)/admin/course-plans/[id]/edit/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/course-plans/[id]/edit/page.tsx#L3-L4)
|
||||
|
||||
**现状**:第 3-4 行从同一模块 `@/modules/course-plans/data-access` 分两行导入:
|
||||
```tsx
|
||||
import { getCoursePlanById } from "@/modules/course-plans/data-access"
|
||||
import { getSubjectOptions } from "@/modules/course-plans/data-access"
|
||||
```
|
||||
|
||||
**修复建议**:合并为单行:
|
||||
```tsx
|
||||
import { getCoursePlanById, getSubjectOptions } from "@/modules/course-plans/data-access"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### P2-7 `scheduling/*` 页面从 `actions` 而非 `data-access` 获取数据
|
||||
|
||||
**违反规范**:
|
||||
- 编码规范 §7.1:「服务端数据获取通过模块的 `data-access.ts` 函数」
|
||||
- 架构影响地图:「actions.ts(编排层:权限 + 调用 data-access + revalidate)」
|
||||
|
||||
**现状**:以下页面从 `@/modules/scheduling/actions` 导入数据查询函数:
|
||||
|
||||
| 文件 | 导入函数 |
|
||||
|------|---------|
|
||||
| `scheduling/auto/page.tsx` | `getAdminClassesForScheduling` |
|
||||
| `scheduling/changes/page.tsx` | `getAdminClassesForScheduling`、`getScheduleChanges` |
|
||||
| `scheduling/rules/page.tsx` | `getAdminClassesForScheduling`、`getSchedulingRules` |
|
||||
|
||||
**说明**:规范允许 `app/` 调用 Server Actions,但 Server Actions 的职责是「编排:权限 + 调用 data-access + revalidate」,主要用于**变更操作**。纯读取操作应通过 `data-access.ts` 暴露,避免在 Server Component 中触发不必要的 `revalidate` 逻辑。
|
||||
|
||||
**修复建议**:将 `getAdminClassesForScheduling`、`getScheduleChanges`、`getSchedulingRules` 等纯查询函数迁移到 `scheduling/data-access.ts`,或在 actions 中明确标注其为只读封装。需同步更新架构文档 004 / 005。
|
||||
|
||||
---
|
||||
|
||||
## 三、React 性能优化建议(基于最佳实践)
|
||||
|
||||
### R1 利用 Suspense 流式渲染提升首屏感知性能
|
||||
|
||||
**现状**:所有页面使用 `export const dynamic = "force-dynamic"` 整页动态渲染,数据获取完成前无任何内容呈现。
|
||||
|
||||
**建议**:对数据量大的页面(`audit-logs/*`、`school/grades/insights`、`attendance`)拆分为多个 Suspense 边界,优先渲染页面骨架,慢查询部分流式注入:
|
||||
|
||||
```tsx
|
||||
import { Suspense } from "react"
|
||||
|
||||
export default async function AuditLogsPage(): Promise<JSX.Element> {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<Header />
|
||||
<Suspense fallback={<FilterSkeleton />}>
|
||||
<Filters />
|
||||
</Suspense>
|
||||
<Suspense fallback={<TableSkeleton />}>
|
||||
<AuditTable />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### R2 `school/grades/insights/page.tsx` 串行查询可优化为并行
|
||||
|
||||
**文件**:[src/app/(dashboard)/admin/school/grades/insights/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/school/grades/insights/page.tsx#L30-L33)
|
||||
|
||||
**现状**:第 30-33 行先 `await getGrades()` 再条件 `await getGradeHomeworkInsights()`,两次串行查询:
|
||||
```tsx
|
||||
const grades = await getGrades()
|
||||
const selected = gradeId && gradeId !== "all" ? gradeId : ""
|
||||
const insights = selected ? await getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : null
|
||||
```
|
||||
|
||||
**说明**:`insights` 依赖 `selected`(来自 URL 参数,非 `grades` 结果),两者无数据依赖,可并行:
|
||||
```tsx
|
||||
const selected = gradeId && gradeId !== "all" ? gradeId : ""
|
||||
const [grades, insights] = await Promise.all([
|
||||
getGrades(),
|
||||
selected ? getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : Promise.resolve(null),
|
||||
])
|
||||
```
|
||||
|
||||
### R3 列表页 `classOptions` 映射可下沉至 data-access
|
||||
|
||||
**现状**:`scheduling/auto`、`scheduling/changes`、`scheduling/rules`、`attendance`、`course-plans/create`、`course-plans/[id]/edit`、`elective/create`、`elective/[id]/edit` 等页面均在组件内 `.map()` 转换数据形状:
|
||||
|
||||
```tsx
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade }))
|
||||
```
|
||||
|
||||
**建议**:在对应 `data-access.ts` 提供 `getClassOptions()`、`getStaffOptions()` 等轻量查询函数,仅返回 `{ id, name }` 形状,减少传输数据量与组件层转换逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 四、Web 界面设计规范建议(基于 WCAG 2.2 AA)
|
||||
|
||||
### W1 表单 `<label>` 与控件关联不规范
|
||||
|
||||
**文件**:[src/app/(dashboard)/admin/school/grades/insights/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/school/grades/insights/page.tsx#L56-L57)
|
||||
|
||||
**现状**:第 56-57 行 `<label>` 与 `<select>` 未通过 `htmlFor` / `id` 关联:
|
||||
```tsx
|
||||
<label className="text-sm font-medium">Grade</label>
|
||||
<select name="gradeId" ...>
|
||||
```
|
||||
|
||||
**违反**:WCAG 2.2 SC 1.3.1(信息与关系)、SC 3.3.2(标签或指令)。
|
||||
|
||||
**修复建议**:
|
||||
```tsx
|
||||
<label htmlFor="grade-filter" className="text-sm font-medium">Grade</label>
|
||||
<select id="grade-filter" name="gradeId" ...>
|
||||
```
|
||||
|
||||
### W2 `users/import/page.tsx` 表格缺少 `<caption>` 与语义化标注
|
||||
|
||||
**文件**:[src/app/(dashboard)/admin/users/import/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/users/import/page.tsx#L93-L128)
|
||||
|
||||
**现状**:原生 `<table>` 缺少 `<caption>` 描述表格用途,屏幕阅读器无法快速理解表格主题。
|
||||
|
||||
**修复建议**:增加 `<caption className="sr-only">模板字段说明</caption>`,或替换为 shadcn Table 后通过 `aria-label` 补充。
|
||||
|
||||
### W3 页面标题层级不统一
|
||||
|
||||
**现状**:
|
||||
- 部分页面使用 `<h2>` 作为页面主标题(如 `school/schools`、`audit-logs`)
|
||||
- `users/import/page.tsx` 也使用 `<h2>`
|
||||
- 但页面布局中未见统一的 `<h1>` 主标题层级
|
||||
|
||||
**建议**:确认 `(dashboard)/layout.tsx` 是否提供 `<h1>` 或页面 `<main>` 的 accessible name,若无,建议各页面统一使用 `<h1>` 作为页面主标题,`<h2>` 用于区块标题,保持标题层级连贯。
|
||||
|
||||
### W4 交互式筛选器缺少 `aria-live` 反馈
|
||||
|
||||
**文件**:`school/grades/insights/page.tsx`、`attendance/page.tsx`、`audit-logs/*`
|
||||
|
||||
**现状**:筛选器提交后表格数据刷新,但屏幕阅读器用户无法感知数据已更新。
|
||||
|
||||
**违反**:WCAG 2.2 SC 4.1.3(状态消息)。
|
||||
|
||||
**建议**:在表格容器添加 `aria-live="polite"` 或使用项目已有的 `useAriaLive` Hook 通知「已加载 N 条记录」。
|
||||
|
||||
### W5 `EmptyState` 组件使用一致但图标语义可优化
|
||||
|
||||
**现状**:`scheduling/*`、`attendance`、`school/grades/insights` 均使用 `EmptyState` 组件,图标统一使用 `ClipboardList` / `BarChart3`,体验一致(优点)。
|
||||
|
||||
**建议**:`BarChart3` 用于「无数据」与「选择年级」两种语义略显混淆,建议「等待操作」类空状态使用 `MousePointerClick` 或 `Filter` 图标区分。
|
||||
|
||||
---
|
||||
|
||||
## 五、优秀实践(已符合规范,应保持)
|
||||
|
||||
1. **服务端组件默认化**:全部 26 个页面均为 async 服务端组件,未滥用 `"use client"`,符合 §5.2。
|
||||
2. **并行数据获取**:`announcements/page.tsx`、`audit-logs/*`、`course-plans/create`、`course-plans/[id]/edit`、`elective/create`、`elective/[id]/edit`、`school/grades`、`scheduling/rules` 等均使用 `Promise.all` 并行查询,性能良好。
|
||||
3. **类型守卫正确使用**:`announcements/page.tsx`、`scheduling/changes/page.tsx`、`course-plans/page.tsx`、`elective/page.tsx` 使用 `isValidStatus` 类型守卫,是 `as` 断言的正确替代方案。
|
||||
4. **404 处理**:`announcements/[id]/page.tsx`、`course-plans/[id]/page.tsx`、`course-plans/[id]/edit/page.tsx`、`elective/[id]/edit/page.tsx` 使用 `notFound()` 处理资源不存在场景。
|
||||
5. **权限校验到位**:`audit-logs/*`(3 个文件)、`files/page.tsx` 正确调用 `requirePermission()`。
|
||||
6. **模块化组合**:页面仅负责数据获取与组合,UI 逻辑下沉至 `modules/*/components/`,符合三层架构。
|
||||
7. **`force-dynamic` 标注**:需要实时数据的页面均显式声明 `export const dynamic = "force-dynamic"`。
|
||||
8. **`metadata` 导出**:`users/import/page.tsx` 正确导出 `metadata` 用于 SEO(建议其他页面补充)。
|
||||
|
||||
---
|
||||
|
||||
## 六、修复优先级与建议执行顺序
|
||||
|
||||
| 优先级 | 问题编号 | 建议执行顺序 | 影响范围 |
|
||||
|--------|---------|-------------|---------|
|
||||
| P0 | P0-2 | 立即修复 attendance 权限 | 1 文件 |
|
||||
| P0 | P0-1 | 补充 error.tsx / loading.tsx | 新增 ~6 文件 |
|
||||
| P1 | P1-1 | 补充返回类型标注 | 26 文件 |
|
||||
| P1 | P1-2 | 抽取共享 getSearchParam | 27 文件 |
|
||||
| P1 | P1-3 | 替换 as 断言为类型守卫 | 4 文件 |
|
||||
| P1 | P1-4 | 统一 UI 文案语言 | ~20 文件 |
|
||||
| P2 | P2-1 ~ P2-7 | 逐步整改 | 单文件级 |
|
||||
| R1 ~ R3 | 性能优化 | 迭代优化 | 关键页面 |
|
||||
| W1 ~ W5 | 可访问性 | 迭代优化 | 关键页面 |
|
||||
|
||||
---
|
||||
|
||||
## 七、附:文件清单与合规状态
|
||||
|
||||
| 文件 | P0 | P1 | P2 | 备注 |
|
||||
|------|----|----|----|----|
|
||||
| `dashboard/page.tsx` | - | 缺返回类型 | - | 整体合规 |
|
||||
| `announcements/page.tsx` | - | 缺返回类型、getParam 重复 | - | 类型守卫正确 |
|
||||
| `announcements/[id]/page.tsx` | - | 缺返回类型、英文文案 | - | - |
|
||||
| `users/import/page.tsx` | - | 缺返回类型 | 原生 table、硬编码颜色 | 文案为中文(正确) |
|
||||
| `school/page.tsx` | - | 缺返回类型 | - | 仅 redirect |
|
||||
| `school/schools/page.tsx` | - | 缺返回类型、英文文案 | - | - |
|
||||
| `school/classes/page.tsx` | - | 缺返回类型、英文文案 | - | - |
|
||||
| `school/grades/page.tsx` | - | 缺返回类型、英文文案 | - | - |
|
||||
| `school/grades/insights/page.tsx` | - | 缺返回类型、英文文案 | 原生 select、任意值、导入顺序、label 未关联 | 问题最多 |
|
||||
| `school/academic-year/page.tsx` | - | 缺返回类型、英文文案 | - | - |
|
||||
| `school/departments/page.tsx` | - | 缺返回类型、英文文案 | - | - |
|
||||
| `audit-logs/page.tsx` | - | 缺返回类型、as 断言、英文文案、getParam 重复 | - | 权限校验正确 |
|
||||
| `audit-logs/login-logs/page.tsx` | - | 缺返回类型、as 断言、英文文案、getParam 重复 | - | 权限校验正确 |
|
||||
| `audit-logs/data-changes/page.tsx` | - | 缺返回类型、as 断言、英文文案、getParam 重复 | - | 权限校验正确 |
|
||||
| `scheduling/auto/page.tsx` | - | 缺返回类型、英文文案 | 从 actions 取数 | - |
|
||||
| `scheduling/changes/page.tsx` | - | 缺返回类型、英文文案、getParam 重复 | 从 actions 取数 | 类型守卫正确 |
|
||||
| `scheduling/rules/page.tsx` | - | 缺返回类型、英文文案 | 从 actions 取数 | - |
|
||||
| `course-plans/page.tsx` | - | 缺返回类型、英文文案、getParam 重复 | - | 类型守卫正确 |
|
||||
| `course-plans/create/page.tsx` | - | 缺返回类型、英文文案 | - | - |
|
||||
| `course-plans/[id]/page.tsx` | - | 缺返回类型 | - | - |
|
||||
| `course-plans/[id]/edit/page.tsx` | - | 缺返回类型、英文文案 | 重复导入 | - |
|
||||
| `elective/page.tsx` | - | 缺返回类型、英文文案、getParam 重复 | - | 类型守卫正确 |
|
||||
| `elective/create/page.tsx` | - | 缺返回类型、英文文案 | - | - |
|
||||
| `elective/[id]/edit/page.tsx` | - | 缺返回类型、英文文案 | - | - |
|
||||
| `attendance/page.tsx` | **缺权限校验** | 缺返回类型、as 断言、英文文案、getParam 重复 | - | 最高优先级 |
|
||||
| `files/page.tsx` | - | 缺返回类型 | - | 权限校验正确、整体合规 |
|
||||
|
||||
---
|
||||
|
||||
> 报告生成完毕。建议按「六、修复优先级」顺序整改,每完成一批次后运行 `npm run lint` 与 `npx tsc --noEmit` 验证,并同步更新架构文档 004 / 005。
|
||||
1434
bugs/back_bug.md
Normal file
1434
bugs/back_bug.md
Normal file
File diff suppressed because it is too large
Load Diff
960
bugs/others_bug.md
Normal file
960
bugs/others_bug.md
Normal file
@@ -0,0 +1,960 @@
|
||||
# `src/app/(dashboard)/{announcements,dashboard,management,messages,profile,settings}` 规范核查报告
|
||||
|
||||
> 核查日期:2026-06-18
|
||||
> 核查范围:`src/app/(dashboard)/` 下的 announcements、dashboard、management、messages、profile、settings 子路由及其直接依赖的模块组件
|
||||
> 依据文档:
|
||||
> - [项目规则](../.trae/rules/project_rules.md)
|
||||
> - [编码规范](../docs/standards/coding-standards.md)
|
||||
> - [架构影响地图 004](../docs/architecture/004_architecture_impact_map.md)
|
||||
> - [架构数据 005](../docs/architecture/005_architecture_data.json)
|
||||
> 应用技能:`vercel-react-best-practices`、`web-design-guidelines`(`web-artifacts-builder` 加载失败,界面优化建议已合并至 web-design-guidelines 章节)
|
||||
|
||||
---
|
||||
|
||||
## 一、核查文件清单
|
||||
|
||||
| 文件 | 行数 | 类型 | 用途 |
|
||||
|------|------|------|------|
|
||||
| [announcements/page.tsx](../src/app/(dashboard)/announcements/page.tsx) | 20 | RSC 页面 | 公告列表(普通用户) |
|
||||
| [dashboard/page.tsx](../src/app/(dashboard)/dashboard/page.tsx) | 18 | RSC 页面 | 角色路由分发 |
|
||||
| [management/grade/classes/page.tsx](../src/app/(dashboard)/management/grade/classes/page.tsx) | 31 | RSC 页面 | 年级班级管理 |
|
||||
| [management/grade/insights/page.tsx](../src/app/(dashboard)/management/grade/insights/page.tsx) | 243 | RSC 页面 | 年级作业洞察 |
|
||||
| [messages/page.tsx](../src/app/(dashboard)/messages/page.tsx) | 31 | RSC 页面 | 消息+通知列表 |
|
||||
| [messages/[id]/page.tsx](../src/app/(dashboard)/messages/[id]/page.tsx) | 30 | RSC 页面 | 消息详情 |
|
||||
| [messages/compose/page.tsx](../src/app/(dashboard)/messages/compose/page.tsx) | 34 | RSC 页面 | 撰写消息 |
|
||||
| [profile/page.tsx](../src/app/(dashboard)/profile/page.tsx) | 305 | RSC 页面 | 个人资料(学生/教师视图) |
|
||||
| [settings/page.tsx](../src/app/(dashboard)/settings/page.tsx) | 32 | RSC 页面 | 设置入口(按角色分发) |
|
||||
| [settings/security/page.tsx](../src/app/(dashboard)/settings/security/page.tsx) | 50 | RSC 页面 | 安全设置 |
|
||||
| [layout.tsx](../src/app/(dashboard)/layout.tsx) | 21 | RSC 布局 | Dashboard 通用布局 |
|
||||
| [error.tsx](../src/app/(dashboard)/error.tsx) | 22 | 客户端组件 | 错误边界 |
|
||||
| [not-found.tsx](../src/app/(dashboard)/not-found.tsx) | 23 | RSC 组件 | 404 页面 |
|
||||
| [modules/announcements/components/announcement-list.tsx](../src/modules/announcements/components/announcement-list.tsx) | 108 | 客户端组件 | 公告列表(含筛选) |
|
||||
| [modules/announcements/components/announcement-card.tsx](../src/modules/announcements/components/announcement-card.tsx) | 79 | 客户端组件 | 公告卡片 |
|
||||
| [modules/announcements/components/announcement-detail.tsx](../src/modules/announcements/components/announcement-detail.tsx) | 206 | 客户端组件 | 公告详情 |
|
||||
| [modules/messaging/components/message-list.tsx](../src/modules/messaging/components/message-list.tsx) | 117 | 客户端组件 | 消息列表 |
|
||||
| [modules/messaging/components/message-detail.tsx](../src/modules/messaging/components/message-detail.tsx) | 153 | 客户端组件 | 消息详情 |
|
||||
| [modules/messaging/components/message-compose.tsx](../src/modules/messaging/components/message-compose.tsx) | 146 | 客户端组件 | 撰写消息表单 |
|
||||
| [modules/messaging/components/notification-list.tsx](../src/modules/messaging/components/notification-list.tsx) | 141 | 客户端组件 | 通知列表 |
|
||||
| [modules/settings/components/admin-settings-view.tsx](../src/modules/settings/components/admin-settings-view.tsx) | 129 | 客户端组件 | 管理员设置视图 |
|
||||
| [modules/settings/components/teacher-settings-view.tsx](../src/modules/settings/components/teacher-settings-view.tsx) | 132 | 客户端组件 | 教师设置视图 |
|
||||
| [modules/settings/components/student-settings-view.tsx](../src/modules/settings/components/student-settings-view.tsx) | 120 | 客户端组件 | 学生设置视图 |
|
||||
| [modules/settings/components/password-change-form.tsx](../src/modules/settings/components/password-change-form.tsx) | 180 | 客户端组件 | 修改密码表单 |
|
||||
| [modules/settings/components/profile-settings-form.tsx](../src/modules/settings/components/profile-settings-form.tsx) | 198 | 客户端组件 | 资料编辑表单 |
|
||||
| [modules/settings/components/notification-preferences-form.tsx](../src/modules/settings/components/notification-preferences-form.tsx) | 260 | 客户端组件 | 通知偏好表单 |
|
||||
| [modules/settings/components/theme-preferences-card.tsx](../src/modules/settings/components/theme-preferences-card.tsx) | 60 | 客户端组件 | 主题偏好 |
|
||||
| [modules/settings/components/ai-provider-settings-card.tsx](../src/modules/settings/components/ai-provider-settings-card.tsx) | 405 | 客户端组件 | AI Provider 配置 |
|
||||
| [modules/classes/components/grade-classes-view.tsx](../src/modules/classes/components/grade-classes-view.tsx) | 455 | 客户端组件 | 年级班级管理视图 |
|
||||
|
||||
---
|
||||
|
||||
## 二、违规问题清单
|
||||
|
||||
### 2.1 [announcements/page.tsx](../src/app/(dashboard)/announcements/page.tsx) — 严重度:高
|
||||
|
||||
#### BUG-A01:缺少权限校验(违反 Server Action 规范)
|
||||
- **位置**:`src/app/(dashboard)/announcements/page.tsx:6-7`
|
||||
- **问题**:页面直接调用 `getAnnouncements({ status: "published" })`,未通过 `requirePermission()` 或 `requireAuth()` 进行任何权限校验
|
||||
- **规范依据**:项目规则「Server Action 必须使用 `requirePermission()` 进行权限校验」;架构文档 004 已记录此问题(P2-12)
|
||||
- **影响**:未登录用户可直接访问 `/announcements` 路由获取公告数据,存在信息泄露风险
|
||||
- **改进建议**:
|
||||
```typescript
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export default async function AnnouncementsPage() {
|
||||
await requirePermission(Permissions.ANNOUNCEMENT_READ)
|
||||
const announcements = await getAnnouncements({ status: "published" })
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### BUG-A02:缺少 `metadata` 导出
|
||||
- **位置**:`src/app/(dashboard)/announcements/page.tsx`
|
||||
- **问题**:未导出 `metadata`,浏览器标签页无标题
|
||||
- **规范依据**:Web Interface Guidelines — Metadata & SEO
|
||||
- **改进建议**:补充 `export const metadata = { title: "Announcements" }`
|
||||
|
||||
---
|
||||
|
||||
### 2.2 [dashboard/page.tsx](../src/app/(dashboard)/dashboard/page.tsx) — 严重度:中
|
||||
|
||||
#### BUG-D01:使用权限反推角色(硬编码反模式)
|
||||
- **位置**:`src/app/(dashboard)/dashboard/page.tsx:14-16`
|
||||
- **问题**:使用 `permissions.includes(HOMEWORK_SUBMIT) && !permissions.includes(EXAM_CREATE)` 反推学生身份,应使用 `hasRole("student")`
|
||||
- **规范依据**:项目规则「前端组件禁止使用 `role === "xxx"` 硬编码,统一使用 `usePermission().hasPermission()`」;架构文档 004 已标记此为 P2 问题
|
||||
- **影响**:当学生被授予 `EXAM_CREATE` 权限(如助教)时会被错误路由到教师页面
|
||||
- **改进建议**:服务端应使用 `session.user.roles` 判断
|
||||
```typescript
|
||||
const roles = session.user.roles ?? []
|
||||
if (roles.includes("admin")) redirect("/admin/dashboard")
|
||||
if (roles.includes("student")) redirect("/student/dashboard")
|
||||
if (roles.includes("parent")) redirect("/parent/dashboard")
|
||||
redirect("/teacher/dashboard")
|
||||
```
|
||||
|
||||
#### BUG-D02:多重 `redirect` 调用难以维护
|
||||
- **位置**:`src/app/(dashboard)/dashboard/page.tsx:14-17`
|
||||
- **问题**:4 个连续 `if + redirect` 缺乏优先级文档说明,新增角色时易遗漏
|
||||
- **改进建议**:抽取为 `resolveDefaultPath(roles)` 单一函数(`proxy.ts` 已有类似实现),保持单一职责
|
||||
|
||||
---
|
||||
|
||||
### 2.3 [management/grade/classes/page.tsx](../src/app/(dashboard)/management/grade/classes/page.tsx) — 严重度:高
|
||||
|
||||
#### BUG-M01:缺少权限校验
|
||||
- **位置**:`src/app/(dashboard)/management/grade/classes/page.tsx:7-15`
|
||||
- **问题**:仅调用 `auth()` 获取 session,未调用 `requirePermission()` 校验 `CLASS_MANAGE` 权限
|
||||
- **规范依据**:项目规则「Server Action 必须使用 `requirePermission()` 进行权限校验」
|
||||
- **影响**:无 `CLASS_MANAGE` 权限的用户可访问页面并获取教师列表、年级数据
|
||||
- **改进建议**:
|
||||
```typescript
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export default async function GradeClassesPage() {
|
||||
const ctx = await requirePermission(Permissions.CLASS_MANAGE)
|
||||
const userId = ctx.userId
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### BUG-M02:`userId` 兜底为空字符串存在隐患
|
||||
- **位置**:`src/app/(dashboard)/management/grade/classes/page.tsx:9`
|
||||
- **问题**:`const userId = session?.user?.id ?? ""` 在未登录时返回空字符串,下游 `getGradeManagedClasses("")` 会查询无意义数据
|
||||
- **改进建议**:未登录应直接 `redirect("/login")`,不应继续执行
|
||||
|
||||
---
|
||||
|
||||
### 2.4 [management/grade/insights/page.tsx](../src/app/(dashboard)/management/grade/insights/page.tsx) — 严重度:高
|
||||
|
||||
#### BUG-MI01:缺少权限校验
|
||||
- **位置**:`src/app/(dashboard)/management/grade/insights/page.tsx:25-34`
|
||||
- **问题**:页面直接调用 `getTeacherIdForMutations()` 和 `getGradesForStaff()`,未调用 `requirePermission()`
|
||||
- **规范依据**:项目规则「Server Action 必须使用 `requirePermission()` 进行权限校验」
|
||||
- **改进建议**:增加 `requirePermission(Permissions.HOMEWORK_READ)` 或对应年级负责人权限校验
|
||||
|
||||
#### BUG-MI02:使用原生 `<select>` 而非 shadcn Select 组件
|
||||
- **位置**:`src/app/(dashboard)/management/grade/insights/page.tsx:70-81`
|
||||
- **问题**:使用原生 `<select>` 元素,与项目其他页面使用的 shadcn `Select` 组件风格不一致
|
||||
- **规范依据**:Web Interface Guidelines — Consistency;项目组件规范
|
||||
- **影响**:视觉风格不统一,无障碍特性差异,主题切换时原生 select 样式无法跟随
|
||||
- **改进建议**:替换为 shadcn `Select` 组件
|
||||
|
||||
#### BUG-MI03:`<label>` 缺少 `htmlFor` 关联
|
||||
- **位置**:`src/app/(dashboard)/management/grade/insights/page.tsx:69`
|
||||
- **问题**:`<label className="text-sm font-medium">Grade</label>` 未关联到 `select` 元素,点击 label 无法聚焦
|
||||
- **规范依据**:Web Interface Guidelines — Forms「Labels properly associated」
|
||||
- **改进建议**:`<label htmlFor="gradeId" className="...">Grade</label>`
|
||||
|
||||
#### BUG-MI04:表单提交触发整页刷新
|
||||
- **位置**:`src/app/(dashboard)/management/grade/insights/page.tsx:68`
|
||||
- **问题**:`<form action="/management/grade/insights" method="get">` 使用原生 GET 提交,导致整页刷新
|
||||
- **违反规则**:`rerender-use-deferred-value`、Next.js 客户端导航最佳实践
|
||||
- **改进建议**:改为客户端组件 + `useRouter().push()` 或使用 `useSearchParams` 实现无刷新筛选
|
||||
|
||||
#### BUG-MI05:`fmt` 工具函数命名过于简短
|
||||
- **位置**:`src/app/(dashboard)/management/grade/insights/page.tsx:23`
|
||||
- **问题**:`const fmt = (v: number | null, digits = 1) => ...` 命名过于简短,不符合可读性要求
|
||||
- **改进建议**:重命名为 `formatScore` 或 `formatNumber`
|
||||
|
||||
---
|
||||
|
||||
### 2.5 [messages/page.tsx](../src/app/(dashboard)/messages/page.tsx) — 严重度:低
|
||||
|
||||
#### BUG-MSG01:缺少 `metadata` 导出
|
||||
- **位置**:`src/app/(dashboard)/messages/page.tsx`
|
||||
- **问题**:未导出 `metadata`
|
||||
- **改进建议**:`export const metadata = { title: "Messages" }`
|
||||
|
||||
---
|
||||
|
||||
### 2.6 [messages/[id]/page.tsx](../src/app/(dashboard)/messages/[id]/page.tsx) — 严重度:中
|
||||
|
||||
#### BUG-MSG02:渲染期间执行写操作(标记已读)
|
||||
- **位置**:`src/app/(dashboard)/messages/[id]/page.tsx:20-23`
|
||||
- **问题**:在 RSC 渲染期间调用 `markMessageAsRead(id, ctx.userId)` 执行写操作
|
||||
- **违反规则**:React Server Components 规范 — 渲染函数应为纯函数,不应有副作用
|
||||
- **影响**:
|
||||
1. React 18+ 严格模式下渲染函数可能被调用两次,导致重复写入
|
||||
2. 流式渲染时若渲染被中断,写操作可能已执行但 UI 未更新
|
||||
3. 错误边界捕获错误后重试渲染会再次执行写操作
|
||||
- **改进建议**:使用 `after()` API 延迟执行非阻塞写操作
|
||||
```typescript
|
||||
import { after } from "next/server"
|
||||
|
||||
if (!message.isRead && message.receiverId === ctx.userId) {
|
||||
after(() => markMessageAsRead(id, ctx.userId))
|
||||
}
|
||||
```
|
||||
- **规范依据**:`vercel-react-best-practices` — `server-after-nonblocking`
|
||||
|
||||
---
|
||||
|
||||
### 2.7 [messages/compose/page.tsx](../src/app/(dashboard)/messages/compose/page.tsx) — 严重度:低
|
||||
|
||||
#### BUG-MSG03:缺少 `metadata` 导出
|
||||
- **改进建议**:`export const metadata = { title: "Compose Message" }`
|
||||
|
||||
---
|
||||
|
||||
### 2.8 [profile/page.tsx](../src/app/(dashboard)/profile/page.tsx) — 严重度:高
|
||||
|
||||
#### BUG-P01:使用权限反推角色(硬编码反模式)
|
||||
- **位置**:`src/app/(dashboard)/profile/page.tsx:47-48`
|
||||
- **问题**:`isStudent = permissions.includes(HOMEWORK_SUBMIT) && !permissions.includes(EXAM_CREATE)`,`isTeacher = permissions.includes(EXAM_CREATE)`
|
||||
- **规范依据**:项目规则禁止硬编码角色判断;架构文档 004 已标记
|
||||
- **改进建议**:使用 `session.user.roles` 判断
|
||||
```typescript
|
||||
const roles = session.user.roles ?? []
|
||||
const isStudent = roles.includes("student")
|
||||
const isTeacher = roles.includes("teacher")
|
||||
```
|
||||
|
||||
#### BUG-P02:在 RSC 中使用 IIFE 异步块(可读性差)
|
||||
- **位置**:`src/app/(dashboard)/profile/page.tsx:50-118`
|
||||
- **问题**:使用 `await (async () => { ... })()` 立即执行异步函数,将学生数据加载逻辑内联在组件中
|
||||
- **影响**:
|
||||
1. 函数体过长(60+ 行),难以测试
|
||||
2. 无法被 React `cache()` 缓存
|
||||
3. 违反单一职责原则
|
||||
- **改进建议**:抽取为 `data-access.ts` 中的 `getStudentProfileData(userId)` 函数
|
||||
```typescript
|
||||
// modules/users/data-access.ts
|
||||
export const getStudentProfileData = cache(async (userId: string) => {
|
||||
const [classes, schedule, assignmentsAll, grades] = await Promise.all([...])
|
||||
// ... 计算逻辑
|
||||
return { enrolledClassCount, dueSoonCount, ... }
|
||||
})
|
||||
```
|
||||
|
||||
#### BUG-P03:本地 `formatDate` 函数与全局工具重复
|
||||
- **位置**:`src/app/(dashboard)/profile/page.tsx:26-33`
|
||||
- **问题**:定义了本地 `formatDate` 函数,与 `@/shared/lib/utils.formatDate` 重复
|
||||
- **影响**:日期格式不一致(本地使用 `en-US`,全局使用 `zh-CN`),维护成本增加
|
||||
- **改进建议**:删除本地函数,使用全局 `formatDate`,或为全局函数增加 `locale` 参数
|
||||
|
||||
#### BUG-P04:`toWeekday` 类型断言不必要
|
||||
- **位置**:`src/app/(dashboard)/profile/page.tsx:21-24`
|
||||
- **问题**:`(day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7` 使用 `as` 断言
|
||||
- **规范依据**:编码规范 4.2.3「禁止 `as` 断言(除非从 `unknown` 转换)」
|
||||
- **改进建议**:使用类型守卫
|
||||
```typescript
|
||||
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
||||
const day = d.getDay()
|
||||
const result = day === 0 ? 7 : day
|
||||
if (result < 1 || result > 7) throw new Error("Invalid weekday")
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
#### BUG-P05:缩进不一致
|
||||
- **位置**:`src/app/(dashboard)/profile/page.tsx:157,167,185,205-207`
|
||||
- **问题**:多处缩进不一致(如 157 行 ` <div` 比 156 行多一个空格)
|
||||
- **规范依据**:`.prettierrc` 配置 `tabWidth: 2`
|
||||
- **改进建议**:运行 `npx prettier --write` 统一格式
|
||||
|
||||
#### BUG-P06:缺少 `metadata` 导出
|
||||
- **改进建议**:`export const metadata = { title: "Profile" }`
|
||||
|
||||
---
|
||||
|
||||
### 2.9 [settings/page.tsx](../src/app/(dashboard)/settings/page.tsx) — 严重度:中
|
||||
|
||||
#### BUG-S01:使用权限反推角色
|
||||
- **位置**:`src/app/(dashboard)/settings/page.tsx:25-30`
|
||||
- **问题**:同 BUG-P01,使用 `permissions.includes(HOMEWORK_SUBMIT) && !permissions.includes(EXAM_CREATE)` 判断学生
|
||||
- **改进建议**:使用 `session.user.roles` 判断
|
||||
|
||||
#### BUG-S02:缺少 `metadata` 导出
|
||||
- **改进建议**:`export const metadata = { title: "Settings" }`
|
||||
|
||||
---
|
||||
|
||||
### 2.10 [settings/security/page.tsx](../src/app/(dashboard)/settings/security/page.tsx) — 严重度:低
|
||||
|
||||
#### BUG-SS01:缺少权限校验
|
||||
- **位置**:`src/app/(dashboard)/settings/security/page.tsx:14-16`
|
||||
- **问题**:仅检查 `session?.user`,未调用 `requirePermission()`
|
||||
- **改进建议**:至少调用 `requireAuth()` 确保登录状态
|
||||
|
||||
---
|
||||
|
||||
### 2.11 [layout.tsx](../src/app/(dashboard)/layout.tsx) — 严重度:中
|
||||
|
||||
#### BUG-L01:跳过链接样式使用任意值
|
||||
- **位置**:`src/app/(dashboard)/layout.tsx:12`
|
||||
- **问题**:`focus:absolute focus:z-50 focus:p-4 focus:bg-background focus:text-foreground focus:border focus:border-border focus:rounded-md focus:m-2` 类名过长且重复
|
||||
- **规范依据**:项目规则「禁止使用任意值(`w-[137px]`)」
|
||||
- **改进建议**:抽取为 `skip-link` 类名或独立组件
|
||||
|
||||
#### BUG-L02:`<main>` 元素缺少 `role="main"`(虽隐式但建议显式)
|
||||
- **位置**:`src/app/(dashboard)/layout.tsx:16`
|
||||
- **问题**:`<main id="main-content">` 已有 `id`,但部分屏幕阅读器需要显式 `role="main"`
|
||||
- **改进建议**:添加 `role="main"`(虽然 HTML5 规范中 `<main>` 隐式 `role="main"`,但为兼容性建议显式)
|
||||
|
||||
---
|
||||
|
||||
### 2.12 [error.tsx](../src/app/(dashboard)/error.tsx) — 严重度:低
|
||||
|
||||
#### BUG-E01:未使用 `error.digest` 信息
|
||||
- **位置**:`src/app/(dashboard)/error.tsx:7`
|
||||
- **问题**:`error` 参数包含 `digest` 字段(用于错误追踪),但未展示给用户或上报
|
||||
- **改进建议**:在描述中包含 `digest` 或提供「复制错误码」按钮
|
||||
|
||||
---
|
||||
|
||||
### 2.13 [not-found.tsx](../src/app/(dashboard)/not-found.tsx) — 严重度:低
|
||||
|
||||
#### BUG-NF01:使用原生 `<a>` 样式而非 Button 组件
|
||||
- **位置**:`src/app/(dashboard)/not-found.tsx:15-20`
|
||||
- **问题**:`<Link className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-9 ...">` 手动拼接 Button 样式
|
||||
- **规范依据**:项目组件规范「使用 `cn()` 工具函数管理条件类名」
|
||||
- **改进建议**:使用 `<Button asChild><Link href="/dashboard">...</Link></Button>`
|
||||
|
||||
---
|
||||
|
||||
### 2.14 [announcement-list.tsx](../src/modules/announcements/components/announcement-list.tsx) — 严重度:中
|
||||
|
||||
#### BUG-AL01:使用 `<a href>` 而非 `<Link>`(全页刷新)
|
||||
- **位置**:`src/modules/announcements/components/announcement-list.tsx:76`
|
||||
- **问题**:`<a href={createHref}>` 使用原生 `<a>` 标签,导致全页刷新
|
||||
- **违反规则**:`vercel-react-best-practices` — Next.js 客户端导航最佳实践
|
||||
- **改进建议**:使用 `next/link` 的 `<Link>` 组件
|
||||
```typescript
|
||||
import Link from "next/link"
|
||||
<Button asChild>
|
||||
<Link href={createHref ?? "#"}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Announcement
|
||||
</Link>
|
||||
</Button>
|
||||
```
|
||||
|
||||
#### BUG-AL02:`handleFilterChange` 未使用 `useCallback`
|
||||
- **位置**:`src/modules/announcements/components/announcement-list.tsx:51-57`
|
||||
- **问题**:`handleFilterChange` 每次渲染创建新引用,传递给 `Select` 的 `onValueChange` 导致不必要重渲染
|
||||
- **违反规则**:`rerender-functional-setstate`、`rerender-memo`
|
||||
- **改进建议**:使用 `useCallback` 包裹
|
||||
|
||||
---
|
||||
|
||||
### 2.15 [announcement-card.tsx](../src/modules/announcements/components/announcement-card.tsx) — 严重度:低
|
||||
|
||||
#### BUG-AC01:`useMemo` 包裹整个 JSX(过度优化)
|
||||
- **位置**:`src/modules/announcements/components/announcement-card.tsx:38-68`
|
||||
- **问题**:使用 `useMemo` 包裹整个卡片 JSX,依赖项为 `[announcement]`(对象)
|
||||
- **违反规则**:`rerender-simple-expression-in-memo` — 简单表达式不需要 memo
|
||||
- **影响**:`announcement` 是对象,每次父组件传入新引用时 memo 失效,无实际优化效果
|
||||
- **改进建议**:移除 `useMemo`,直接渲染 JSX;如需优化应使用 `React.memo` 包裹组件
|
||||
```typescript
|
||||
export const AnnouncementCard = React.memo(function AnnouncementCard({...}) {
|
||||
return <Card>...</Card>
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.16 [announcement-detail.tsx](../src/modules/announcements/components/announcement-detail.tsx) — 严重度:中
|
||||
|
||||
#### BUG-AD01:使用 `<a href>` 而非 `<Link>`
|
||||
- **位置**:`src/modules/announcements/components/announcement-detail.tsx:123,146`
|
||||
- **问题**:`backHref` 和 `editHref` 使用原生 `<a>` 标签
|
||||
- **改进建议**:替换为 `next/link`
|
||||
|
||||
#### BUG-AD02:三个处理函数未 `useCallback`
|
||||
- **位置**:`src/modules/announcements/components/announcement-detail.tsx:64-115`
|
||||
- **问题**:`handlePublish`、`handleArchive`、`handleDelete` 每次渲染创建新引用
|
||||
- **违反规则**:`rerender-functional-setstate`
|
||||
- **改进建议**:使用 `useCallback` 包裹
|
||||
|
||||
---
|
||||
|
||||
### 2.17 [message-list.tsx](../src/modules/messaging/components/message-list.tsx) — 严重度:中
|
||||
|
||||
#### BUG-ML01:使用字符串拼接动态类名
|
||||
- **位置**:`src/modules/messaging/components/message-list.tsx:82,91`
|
||||
- **问题**:`` className={`transition-colors hover:bg-accent/50 ${unread ? "border-primary/40" : ""}`} `` 使用模板字符串拼接类名
|
||||
- **规范依据**:项目规则「使用 `cn()` 工具函数管理条件类名」
|
||||
- **改进建议**:
|
||||
```typescript
|
||||
className={cn(
|
||||
"transition-colors hover:bg-accent/50",
|
||||
unread && "border-primary/40"
|
||||
)}
|
||||
```
|
||||
|
||||
#### BUG-ML02:`usePermission` 在客户端组件中导致 hydration 风险
|
||||
- **位置**:`src/modules/messaging/components/message-list.tsx:30-31`
|
||||
- **问题**:`usePermission()` 依赖 `useSession()`,服务端渲染时返回空权限,客户端首次渲染后才有权限,导致「Compose」按钮在 hydration 后闪烁
|
||||
- **违反规则**:Web Interface Guidelines — Hydration Safety
|
||||
- **改进建议**:将 `canSend` 作为 prop 从 RSC 父组件传入
|
||||
|
||||
---
|
||||
|
||||
### 2.18 [message-detail.tsx](../src/modules/messaging/components/message-detail.tsx) — 严重度:中
|
||||
|
||||
#### BUG-MD01:使用 `<a href>` 而非 `<Link>`
|
||||
- **位置**:`src/modules/messaging/components/message-detail.tsx:79`
|
||||
- **问题**:`<a href={backHref}>` 使用原生 `<a>`
|
||||
- **改进建议**:替换为 `next/link`
|
||||
|
||||
#### BUG-MD02:`replyHref` 为 `undefined` 时仍渲染 Link
|
||||
- **位置**:`src/modules/messaging/components/message-detail.tsx:68,87-92`
|
||||
- **问题**:当 `canSend` 为 false 时 `replyHref` 为 `undefined`,但代码使用 `<Link href={replyHref ?? "#"}>` 仍渲染可点击链接,点击后跳转到 `#`
|
||||
- **影响**:用户体验差,点击无效链接
|
||||
- **改进建议**:`canSend` 为 false 时不渲染 Reply 按钮(当前已有 `{canSend ? ... : null}` 包裹,但内部仍用 `?? "#"` 兜底,应直接使用 `replyHref!` 或移除兜底)
|
||||
|
||||
#### BUG-MD03:URL 参数未编码
|
||||
- **位置**:`src/modules/messaging/components/message-detail.tsx:69-71`
|
||||
- **问题**:`subject=${encodeURIComponent(...)}` 已编码 subject,但 `parentId` 和 `receiverId` 未编码(虽然 UUID 不含特殊字符,但不严谨)
|
||||
- **改进建议**:使用 `URLSearchParams` 构建查询字符串
|
||||
```typescript
|
||||
const params = new URLSearchParams({
|
||||
parentId: message.id,
|
||||
receiverId: isReceived ? message.senderId : message.receiverId,
|
||||
subject: message.subject?.startsWith("Re:") ? message.subject : `Re: ${message.subject ?? ""}`,
|
||||
})
|
||||
const replyHref = canSend ? `/messages/compose?${params.toString()}` : undefined
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.19 [message-compose.tsx](../src/modules/messaging/components/message-compose.tsx) — 严重度:中
|
||||
|
||||
#### BUG-MC01:使用 `<a href>` 而非 `<Link>`
|
||||
- **位置**:`src/modules/messaging/components/message-compose.tsx:73`
|
||||
- **问题**:返回按钮使用原生 `<a>`
|
||||
- **改进建议**:替换为 `next/link`
|
||||
|
||||
#### BUG-MC02:隐藏 input 与 `formData.set` 重复
|
||||
- **位置**:`src/modules/messaging/components/message-compose.tsx:46,97`
|
||||
- **问题**:`handleSubmit` 中 `formData.set("receiverId", receiverId)`,同时 JSX 中又有 `<input type="hidden" name="receiverId" value={receiverId} />`,两者重复
|
||||
- **改进建议**:移除隐藏 input,仅使用 `formData.set`
|
||||
|
||||
#### BUG-MC03:`handleSubmit` 未 `useCallback`
|
||||
- **位置**:`src/modules/messaging/components/message-compose.tsx:41-66`
|
||||
- **改进建议**:使用 `useCallback` 包裹
|
||||
|
||||
---
|
||||
|
||||
### 2.20 [notification-list.tsx](../src/modules/messaging/components/notification-list.tsx) — 严重度:中
|
||||
|
||||
#### BUG-NL01:使用字符串拼接动态类名
|
||||
- **位置**:`src/modules/messaging/components/notification-list.tsx:94,102`
|
||||
- **问题**:`` className={`transition-colors ${!n.isRead ? "border-primary/40 bg-primary/5" : ""}`} ``
|
||||
- **规范依据**:项目规则「使用 `cn()` 工具函数管理条件类名」
|
||||
- **改进建议**:使用 `cn()`
|
||||
|
||||
#### BUG-NL02:`handleMarkRead` 未 `useCallback`
|
||||
- **位置**:`src/modules/messaging/components/notification-list.tsx:54-63`
|
||||
- **改进建议**:使用 `useCallback`
|
||||
|
||||
#### BUG-NL03:`<button>` 元素缺少 `type` 属性
|
||||
- **位置**:`src/modules/messaging/components/notification-list.tsx:118-124`
|
||||
- **问题**:`<button onClick={...}>` 未指定 `type="button"`,默认为 `submit`,若被表单包裹会触发提交
|
||||
- **规范依据**:Web Interface Guidelines — Forms
|
||||
- **改进建议**:添加 `type="button"`
|
||||
|
||||
---
|
||||
|
||||
### 2.21 [password-change-form.tsx](../src/modules/settings/components/password-change-form.tsx) — 严重度:高
|
||||
|
||||
#### BUG-PC01:使用字符串拼接动态类名(严重违规)
|
||||
- **位置**:`src/modules/settings/components/password-change-form.tsx:133`
|
||||
- **问题**:`` className={`h-2 [&>div]:${meta.color}`} `` 动态拼接 Tailwind 类名
|
||||
- **规范依据**:项目规则「**禁止**字符串拼接动态类名(`bg-${color}-500`)」
|
||||
- **影响**:Tailwind JIT 无法识别动态拼接的类名,`bg-red-500`、`bg-yellow-500`、`bg-green-500` 可能被 tree-shaking 移除,导致生产环境进度条无颜色
|
||||
- **改进建议**:使用映射对象 + `cn()`
|
||||
```typescript
|
||||
const STRENGTH_BAR_CLASS: Record<PasswordStrength, string> = {
|
||||
weak: "h-2 [&>div]:bg-red-500",
|
||||
medium: "h-2 [&>div]:bg-yellow-500",
|
||||
strong: "h-2 [&>div]:bg-green-500",
|
||||
}
|
||||
|
||||
<Progress value={meta.value} className={STRENGTH_BAR_CLASS[strength]} />
|
||||
```
|
||||
|
||||
#### BUG-PC02:使用 `document.getElementById` 操作 DOM(反 React 模式)
|
||||
- **位置**:`src/modules/settings/components/password-change-form.tsx:62-63`
|
||||
- **问题**:`const form = document.getElementById("password-change-form") as HTMLFormElement | null` 直接操作 DOM
|
||||
- **规范依据**:React 最佳实践 — 避免直接 DOM 操作
|
||||
- **改进建议**:使用 `useRef<HTMLFormElement>` 或受控组件重置表单
|
||||
```typescript
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
// ...
|
||||
formRef.current?.reset()
|
||||
```
|
||||
|
||||
#### BUG-PC03:`as` 断言使用
|
||||
- **位置**:`src/modules/settings/components/password-change-form.tsx:62`
|
||||
- **问题**:`as HTMLFormElement | null` 使用类型断言
|
||||
- **规范依据**:编码规范 4.2.3「禁止 `as` 断言」
|
||||
- **改进建议**:使用 `useRef` 后通过 ref.current 的类型推导
|
||||
|
||||
---
|
||||
|
||||
### 2.22 [profile-settings-form.tsx](../src/modules/settings/components/profile-settings-form.tsx) — 严重度:高
|
||||
|
||||
#### BUG-PS01:使用 `as any` 类型断言(严重违规)
|
||||
- **位置**:`src/modules/settings/components/profile-settings-form.tsx:35`
|
||||
- **问题**:`resolver: zodResolver(profileFormSchema) as any` 使用 `as any`
|
||||
- **规范依据**:项目规则「**禁止 `any`**」「**禁止 `as` 断言**」
|
||||
- **改进建议**:修复 `zodResolver` 类型不匹配问题
|
||||
```typescript
|
||||
// 方案 1:使用 react-hook-form 的 Resolver 类型
|
||||
import type { Resolver } from "react-hook-form"
|
||||
const resolver: Resolver<ProfileFormValues> = zodResolver(profileFormSchema)
|
||||
|
||||
// 方案 2:修正 schema 类型定义
|
||||
const profileFormSchema = z.object({...}) satisfies z.ZodType<ProfileFormValues>
|
||||
```
|
||||
|
||||
#### BUG-PS02:`console.error` 残留
|
||||
- **位置**:`src/modules/settings/components/profile-settings-form.tsx:60`
|
||||
- **问题**:`console.error(error)` 在生产代码中残留
|
||||
- **规范依据**:编码规范 — 生产代码不应包含 `console.*`
|
||||
- **改进建议**:移除或替换为日志服务
|
||||
|
||||
#### BUG-PS03:`onSubmit` 未 `useCallback`
|
||||
- **位置**:`src/modules/settings/components/profile-settings-form.tsx:47-63`
|
||||
- **改进建议**:使用 `useCallback`
|
||||
|
||||
#### BUG-PS04:`age` 字段使用 `z.coerce.number()` 但未处理 NaN
|
||||
- **位置**:`src/modules/settings/components/profile-settings-form.tsx:25`
|
||||
- **问题**:`age: z.coerce.number().min(0).optional()` 当输入为空字符串时会转换为 `0`,而非 `undefined`
|
||||
- **改进建议**:使用 `z.preprocess` 处理空值
|
||||
```typescript
|
||||
age: z.preprocess(
|
||||
(v) => (v === "" || v === null || v === undefined ? undefined : Number(v)),
|
||||
z.number().min(0).optional()
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.23 [notification-preferences-form.tsx](../src/modules/settings/components/notification-preferences-form.tsx) — 严重度:中
|
||||
|
||||
#### BUG-NPF01:Switch 与隐藏 checkbox 状态同步问题
|
||||
- **位置**:`src/modules/settings/components/notification-preferences-form.tsx:186-201,233-248`
|
||||
- **问题**:同时使用隐藏 `<input type="checkbox">` 和 `<Switch>`,两者都调用 `toggleChannel`/`toggleCategory`,可能导致双重切换
|
||||
- **影响**:用户点击 Switch 时,`onCheckedChange` 触发;同时隐藏 checkbox 的 `onChange` 也触发,导致状态切换两次回到原点
|
||||
- **改进建议**:移除隐藏 checkbox,仅使用 Switch + 隐藏 input(`type="hidden"`)提交表单
|
||||
```typescript
|
||||
<input type="hidden" name={item.key} value={checked ? "true" : "false"} />
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={() => toggleChannel(item.key)}
|
||||
aria-label={item.label}
|
||||
/>
|
||||
```
|
||||
|
||||
#### BUG-NPF02:本地状态与服务器状态可能不同步
|
||||
- **位置**:`src/modules/settings/components/notification-preferences-form.tsx:122-133`
|
||||
- **问题**:`useState` 初始化自 `preferences` prop,但 prop 变化时状态不更新
|
||||
- **违反规则**:`rerender-derived-state-no-effect` — 不应使用 effect 同步派生状态
|
||||
- **改进建议**:使用 `key` prop 重置组件,或使用受控组件
|
||||
|
||||
#### BUG-NPF03:中文注释混合英文代码
|
||||
- **位置**:`src/modules/settings/components/notification-preferences-form.tsx:121,161,209`
|
||||
- **问题**:`// 本地状态用于即时反馈 Switch 切换`、`{/* 通知渠道 */}`、`{/* 通知类别 */}` 中文注释
|
||||
- **规范依据**:项目代码一致性(其他文件使用英文注释)
|
||||
- **改进建议**:统一为英文注释
|
||||
|
||||
---
|
||||
|
||||
### 2.24 [theme-preferences-card.tsx](../src/modules/settings/components/theme-preferences-card.tsx) — 严重度:低
|
||||
|
||||
#### BUG-TP01:`"use client"` 后缺少空行
|
||||
- **位置**:`src/modules/settings/components/theme-preferences-card.tsx:1-2`
|
||||
- **问题**:`"use client"` 紧跟 `import` 无空行
|
||||
- **规范依据**:`.prettierrc` 格式规范
|
||||
- **改进建议**:运行 `npx prettier --write`
|
||||
|
||||
#### BUG-TP02:`setTheme` 参数类型不安全
|
||||
- **位置**:`src/modules/settings/components/theme-preferences-card.tsx:31`
|
||||
- **问题**:`onValueChange={(v) => setTheme(v)}` 中 `v` 为 `string`,但 `setTheme` 期望特定类型
|
||||
- **改进建议**:`onValueChange={(v) => setTheme(v as ThemeChoice)}`(虽然违反 as 规范,但 next-themes 类型定义如此;或使用类型守卫)
|
||||
|
||||
---
|
||||
|
||||
### 2.25 [ai-provider-settings-card.tsx](../src/modules/settings/components/ai-provider-settings-card.tsx) — 严重度:高
|
||||
|
||||
#### BUG-AI01:中英文混合 UI(严重一致性违规)
|
||||
- **位置**:`src/modules/settings/components/ai-provider-settings-card.tsx:298,325,352,367`
|
||||
- **问题**:FormLabel 使用中文「品牌方」「设为默认」,FormDescription 使用中文「填写基础地址,不要包含 /chat/completions。」「不会回显历史 Key,留空表示不更新。」
|
||||
- **规范依据**:Web Interface Guidelines — Consistency;项目其他 UI 均为英文
|
||||
- **影响**:用户在英文界面中突然看到中文,体验割裂
|
||||
- **改进建议**:统一为英文
|
||||
```typescript
|
||||
<FormLabel>Provider</FormLabel>
|
||||
<FormDescription>Enter base URL without /chat/completions suffix.</FormDescription>
|
||||
<FormLabel>Set as default</FormLabel>
|
||||
<FormDescription>Existing key won't be displayed. Leave blank to keep current.</FormDescription>
|
||||
```
|
||||
|
||||
#### BUG-AI02:`useEffect` 依赖项过多导致重复执行
|
||||
- **位置**:`src/modules/settings/components/ai-provider-settings-card.tsx:108-136`
|
||||
- **问题**:`useEffect` 依赖 `[form, selectedId, onProvidersChanged, initialMode, resetToNew]`,但使用 `loadedRef` 防止重复执行
|
||||
- **违反规则**:`rerender-dependencies` — 应使用原始依赖
|
||||
- **改进建议**:将初始化逻辑移至 `useEffect` 内部,依赖项仅为 `[]`(仅执行一次)
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
startTransition(async () => {
|
||||
const rows = await getAiProviderSummaries()
|
||||
if (cancelled) return
|
||||
// ...
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, []) // 仅挂载时执行
|
||||
```
|
||||
|
||||
#### BUG-AI03:`handleSelectChange` 未 `useCallback`
|
||||
- **位置**:`src/modules/settings/components/ai-provider-settings-card.tsx:138-156`
|
||||
- **改进建议**:使用 `useCallback`
|
||||
|
||||
#### BUG-AI04:文件行数 405 行,接近上限
|
||||
- **位置**:`src/modules/settings/components/ai-provider-settings-card.tsx`
|
||||
- **问题**:文件 405 行,项目规则建议 React 组件 ≤ 500 行,但复杂度较高
|
||||
- **改进建议**:考虑拆分为 `AiProviderSelect`、`AiProviderForm`、`AiProviderTestButton` 子组件
|
||||
|
||||
---
|
||||
|
||||
### 2.26 [admin-settings-view.tsx](../src/modules/settings/components/admin-settings-view.tsx) — 严重度:低
|
||||
|
||||
#### BUG-AS01:Tab 图标语义错误
|
||||
- **位置**:`src/modules/settings/components/admin-settings-view.tsx:50-53`
|
||||
- **问题**:`appearance` Tab 使用 `<Shield />` 图标(盾牌通常表示安全),应使用 `<Palette />` 或 `<Monitor />`
|
||||
- **规范依据**:Web Interface Guidelines — Iconography
|
||||
- **改进建议**:`<TabsTrigger value="appearance"><Palette /></TabsTrigger>`
|
||||
|
||||
#### BUG-AS02:`signOut` 直接调用未确认
|
||||
- **位置**:`src/modules/settings/components/admin-settings-view.tsx:120`
|
||||
- **问题**:`onClick={() => signOut({ callbackUrl: "/login" })}` 直接登出,无确认对话框
|
||||
- **规范依据**:Web Interface Guidelines — Destructive Actions
|
||||
- **改进建议**:增加确认对话框(虽然登出非破坏性,但意外登出影响体验)
|
||||
|
||||
---
|
||||
|
||||
### 2.27 [teacher-settings-view.tsx](../src/modules/settings/components/teacher-settings-view.tsx) — 严重度:低
|
||||
|
||||
#### BUG-TS01:与 admin-settings-view.tsx 大量重复代码
|
||||
- **位置**:`src/modules/settings/components/teacher-settings-view.tsx`
|
||||
- **问题**:与 `admin-settings-view.tsx`、`student-settings-view.tsx` 90% 代码重复,仅「Back to dashboard」链接和「Quick links」不同
|
||||
- **规范依据**:DRY 原则
|
||||
- **改进建议**:抽取为 `SettingsLayout` 共享组件,通过 props 传入 `backHref` 和 `quickLinks`
|
||||
```typescript
|
||||
export function SettingsLayout({ title, description, backHref, quickLinks, children }: {...}) {
|
||||
return <div>...</div>
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.28 [student-settings-view.tsx](../src/modules/settings/components/student-settings-view.tsx) — 严重度:低
|
||||
|
||||
#### BUG-ST01:同 BUG-TS01,代码重复
|
||||
- **改进建议**:同 BUG-TS01
|
||||
|
||||
---
|
||||
|
||||
### 2.29 [grade-classes-view.tsx](../src/modules/classes/components/grade-classes-view.tsx) — 严重度:高
|
||||
|
||||
#### BUG-GC01:文件 455 行,超过 500 行建议上限的 91%
|
||||
- **位置**:`src/modules/classes/components/grade-classes-view.tsx`
|
||||
- **问题**:单文件 455 行,包含列表、创建对话框、编辑对话框、删除确认对话框
|
||||
- **规范依据**:项目规则「React 组件:建议 ≤ 500 行」
|
||||
- **改进建议**:拆分为:
|
||||
- `grade-classes-view.tsx`(主视图,< 100 行)
|
||||
- `grade-class-create-dialog.tsx`
|
||||
- `grade-class-edit-dialog.tsx`
|
||||
- `grade-class-delete-dialog.tsx`
|
||||
|
||||
#### BUG-GC02:`useEffect` 依赖项导致不必要重渲染
|
||||
- **位置**:`src/modules/classes/components/grade-classes-view.tsx:62-78`
|
||||
- **问题**:两个 `useEffect` 依赖 `managedGrades` 数组引用,父组件每次传入新数组都会触发
|
||||
- **违反规则**:`rerender-dependencies`
|
||||
- **改进建议**:依赖 `managedGrades[0]?.id` 而非整个数组
|
||||
|
||||
#### BUG-GC03:中英文混合 UI
|
||||
- **位置**:`src/modules/classes/components/grade-classes-view.tsx:183-184,283,370,389`
|
||||
- **问题**:表头「班主任」「任课老师」使用中文,其他列使用英文
|
||||
- **规范依据**:Web Interface Guidelines — Consistency
|
||||
- **改进建议**:统一为英文 `Homeroom Teacher`、`Subject Teachers`
|
||||
|
||||
#### BUG-GC04:`formatSubjectTeachers` 在每次渲染时重新创建
|
||||
- **位置**:`src/modules/classes/components/grade-classes-view.tsx:140-146`
|
||||
- **问题**:函数在组件内定义,每次渲染创建新引用
|
||||
- **改进建议**:移至模块级别(不依赖组件状态)
|
||||
|
||||
---
|
||||
|
||||
## 三、React 性能优化(应用 `vercel-react-best-practices` 技能)
|
||||
|
||||
### 3.1 重渲染优化
|
||||
|
||||
#### PERF-01:`usePermission` 返回的回调未 memoize
|
||||
- **位置**:`src/shared/hooks/use-permission.ts:11-25`
|
||||
- **问题**:`hasPermission`、`hasAnyPermission`、`hasAllPermissions`、`hasRole` 每次渲染创建新函数引用
|
||||
- **违反规则**:`rerender-functional-setstate`、`rerender-memo`
|
||||
- **影响**:`message-list.tsx`、`message-detail.tsx` 中使用 `usePermission()` 的组件每次渲染都创建新 `canSend`/`canDelete` 值
|
||||
- **改进建议**:使用 `useCallback` 包裹所有回调(详见 `student_bug.md` PERF-01)
|
||||
|
||||
#### PERF-02:`AnnouncementCard` 的 `useMemo` 无效
|
||||
- **位置**:`src/modules/announcements/components/announcement-card.tsx:38-68`
|
||||
- **问题**:`useMemo` 依赖 `[announcement]`(对象),父组件每次渲染传入新引用,memo 失效
|
||||
- **违反规则**:`rerender-simple-expression-in-memo`
|
||||
- **改进建议**:移除 `useMemo`,使用 `React.memo` 包裹组件
|
||||
|
||||
#### PERF-03:`profile/page.tsx` 串行 await 未并行化
|
||||
- **位置**:`src/app/(dashboard)/profile/page.tsx:53-58`
|
||||
- **问题**:学生数据加载使用 `Promise.all` ✅,但 `userProfile` 和 `studentData` 是串行执行
|
||||
- **违反规则**:`async-parallel`
|
||||
- **改进建议**:`userProfile` 和角色判断后,并行加载学生/教师数据(当前已是此模式,但 `userProfile` 必须先获取才能判断角色,无法并行)
|
||||
|
||||
#### PERF-04:`messages/page.tsx` 已正确使用 `Promise.all`
|
||||
- **位置**:`src/app/(dashboard)/messages/page.tsx:12-15`
|
||||
- **现状**:✅ 已使用 `Promise.all` 并行加载 messages 和 notifications
|
||||
|
||||
#### PERF-05:`management/grade/classes/page.tsx` 已正确使用 `Promise.all`
|
||||
- **位置**:`src/app/(dashboard)/management/grade/classes/page.tsx:11-15`
|
||||
- **现状**:✅ 已并行加载 classes、teachers、managedGrades
|
||||
|
||||
#### PERF-06:`ai-provider-settings-card.tsx` 使用 `loadedRef` 防止重复加载
|
||||
- **位置**:`src/modules/settings/components/ai-provider-settings-card.tsx:66,109-110`
|
||||
- **问题**:使用 `loadedRef` 而非空依赖 `useEffect`
|
||||
- **违反规则**:`rerender-dependencies`
|
||||
- **改进建议**:使用空依赖数组 `[]` + 清理函数
|
||||
|
||||
### 3.2 Bundle Size 优化
|
||||
|
||||
#### PERF-07:`lucide-react` 导入方式
|
||||
- **位置**:多处,如 `src/app/(dashboard)/profile/page.tsx:17`
|
||||
- **问题**:`import { User, Mail, Phone, MapPin, Calendar, Clock, Shield } from "lucide-react"` 从 barrel 文件导入
|
||||
- **违反规则**:`bundle-barrel-imports`
|
||||
- **现状**:Next.js 13+ 自动 tree-shaking `lucide-react`,影响较小
|
||||
- **改进建议**:保持现状,但确保 `next.config.js` 启用了 `optimizePackageImports`
|
||||
|
||||
### 3.3 服务端性能
|
||||
|
||||
#### PERF-08:`profile/page.tsx` 数据加载未使用 `cache()`
|
||||
- **位置**:`src/app/(dashboard)/profile/page.tsx:50-118`
|
||||
- **问题**:学生数据加载逻辑内联在组件中,无法被 React `cache()` 去重
|
||||
- **违反规则**:`server-cache-react`
|
||||
- **改进建议**:抽取为 `data-access.ts` 中的 `cache()` 包裹函数
|
||||
|
||||
#### PERF-09:`messages/[id]/page.tsx` 渲染期间写操作
|
||||
- **位置**:`src/app/(dashboard)/messages/[id]/page.tsx:20-23`
|
||||
- **问题**:渲染期间调用 `markMessageAsRead` 执行写操作
|
||||
- **违反规则**:`server-after-nonblocking`
|
||||
- **改进建议**:使用 `after()` API
|
||||
|
||||
---
|
||||
|
||||
## 四、Web 界面规范审查(应用 `web-design-guidelines` 技能)
|
||||
|
||||
### 4.1 Hydration Safety
|
||||
|
||||
#### UI-01:`usePermission` 导致 hydration 闪烁
|
||||
- **位置**:`src/modules/messaging/components/message-list.tsx:30-31`、`src/modules/messaging/components/message-detail.tsx:41-43`
|
||||
- **问题**:`usePermission()` 依赖 `useSession()`,服务端渲染时无权限,客户端 hydration 后权限相关 UI(Compose、Reply、Delete 按钮)闪烁出现
|
||||
- **违反规则**:Web Interface Guidelines — Hydration Safety
|
||||
- **改进建议**:将权限判断结果作为 prop 从 RSC 父组件传入
|
||||
```typescript
|
||||
// RSC 父组件
|
||||
const canSend = ctx.permissions.includes(Permissions.MESSAGE_SEND)
|
||||
<MessageList messages={...} canSend={canSend} />
|
||||
```
|
||||
|
||||
#### UI-02:`theme-preferences-card.tsx` 已使用 `suppressHydrationWarning`
|
||||
- **位置**:`src/modules/settings/components/theme-preferences-card.tsx:32`
|
||||
- **现状**:✅ 已正确处理主题切换的 hydration 问题
|
||||
|
||||
### 4.2 Navigation & State
|
||||
|
||||
#### UI-03:使用 `<a href>` 导致全页刷新
|
||||
- **位置**:多处(BUG-AL01、BUG-AD01、BUG-MD01、BUG-MC01)
|
||||
- **问题**:使用原生 `<a>` 而非 `<Link>`,破坏 SPA 导航
|
||||
- **违反规则**:Web Interface Guidelines — Navigation
|
||||
- **改进建议**:全部替换为 `next/link`
|
||||
|
||||
#### UI-04:`announcement-list.tsx` 筛选状态未反映在 URL
|
||||
- **位置**:`src/modules/announcements/components/announcement-list.tsx:51-57`
|
||||
- **问题**:`handleFilterChange` 使用 `router.replace(qs ? ?${qs} : ?)` 更新 URL ✅,但初始 `filter` 状态来自 `initialStatus` prop 而非 URL
|
||||
- **改进建议**:使用 `useSearchParams` 读取 URL 状态
|
||||
|
||||
#### UI-05:`message-detail.tsx` 回复链接 URL 参数构建不严谨
|
||||
- **位置**:`src/modules/messaging/components/message-detail.tsx:69-71`
|
||||
- **问题**:手动拼接 URL 参数,未使用 `URLSearchParams`
|
||||
- **改进建议**:见 BUG-MD03
|
||||
|
||||
### 4.3 Forms
|
||||
|
||||
#### UI-06:`management/grade/insights/page.tsx` label 未关联 select
|
||||
- **位置**:`src/app/(dashboard)/management/grade/insights/page.tsx:69`
|
||||
- **问题**:`<label>` 缺少 `htmlFor`
|
||||
- **违反规则**:Web Interface Guidelines — Forms
|
||||
- **改进建议**:见 BUG-MI03
|
||||
|
||||
#### UI-07:`notification-list.tsx` button 缺少 `type` 属性
|
||||
- **位置**:`src/modules/messaging/components/notification-list.tsx:118`
|
||||
- **问题**:`<button>` 未指定 `type="button"`
|
||||
- **违反规则**:Web Interface Guidelines — Forms
|
||||
- **改进建议**:见 BUG-NL03
|
||||
|
||||
#### UI-08:`message-compose.tsx` 表单提交使用 `formData.set` 而非受控组件
|
||||
- **位置**:`src/modules/messaging/components/message-compose.tsx:46-49`
|
||||
- **问题**:混合使用受控(`receiverId` state)和非受控(FormData)模式
|
||||
- **改进建议**:统一使用受控组件或完全使用 FormData
|
||||
|
||||
### 4.4 Content & Copy
|
||||
|
||||
#### UI-09:中英文混合 UI
|
||||
- **位置**:
|
||||
- `ai-provider-settings-card.tsx`:BUG-AI01
|
||||
- `grade-classes-view.tsx`:BUG-GC03
|
||||
- `notification-preferences-form.tsx`:BUG-NPF03(注释)
|
||||
- **违反规则**:Web Interface Guidelines — Consistency
|
||||
- **改进建议**:统一为英文
|
||||
|
||||
#### UI-10:错误消息缺少修复步骤
|
||||
- **位置**:`src/app/(dashboard)/error.tsx:13`
|
||||
- **问题**:`"We apologize for the inconvenience. An unexpected error occurred."` 未提供下一步操作
|
||||
- **违反规则**:Web Interface Guidelines — Content & Copy
|
||||
- **改进建议**:增加「联系管理员」链接或错误码展示
|
||||
|
||||
#### UI-11:`admin-settings-view.tsx` Tab 图标语义错误
|
||||
- **位置**:`src/modules/settings/components/admin-settings-view.tsx:50-53`
|
||||
- **问题**:Appearance Tab 使用 Shield 图标
|
||||
- **违反规则**:Web Interface Guidelines — Iconography
|
||||
- **改进建议**:见 BUG-AS01
|
||||
|
||||
### 4.5 Accessibility
|
||||
|
||||
#### UI-12:`notification-list.tsx` icon 按钮缺少 `aria-label`
|
||||
- **位置**:`src/modules/messaging/components/notification-list.tsx:118-124`
|
||||
- **问题**:「Mark as read」按钮文本存在,但图标按钮模式未统一
|
||||
- **改进建议**:确保所有图标按钮有 `aria-label`
|
||||
|
||||
#### UI-13:`layout.tsx` 跳过链接样式冗长
|
||||
- **位置**:`src/app/(dashboard)/layout.tsx:12`
|
||||
- **问题**:跳过链接使用大量 `focus:` 前缀类名,难以维护
|
||||
- **改进建议**:抽取为独立样式或组件
|
||||
|
||||
### 4.6 Performance
|
||||
|
||||
#### UI-14:`management/grade/insights/page.tsx` 表单提交整页刷新
|
||||
- **位置**:`src/app/(dashboard)/management/grade/insights/page.tsx:68`
|
||||
- **问题**:原生 form GET 提交导致整页刷新
|
||||
- **违反规则**:Web Interface Guidelines — Performance
|
||||
- **改进建议**:见 BUG-MI04
|
||||
|
||||
#### UI-15:`profile/page.tsx` 内联数据处理逻辑
|
||||
- **位置**:`src/app/(dashboard)/profile/page.tsx:60-108`
|
||||
- **问题**:在组件内执行数组排序、过滤等耗时操作
|
||||
- **改进建议**:移至 data-access 层
|
||||
|
||||
---
|
||||
|
||||
## 五、架构文档同步问题
|
||||
|
||||
### 5.1 [004_architecture_impact_map.md](../docs/architecture/004_architecture_impact_map.md)
|
||||
|
||||
#### DOC-01:announcements 模块未记录页面缺少权限校验
|
||||
- **位置**:004 文档 2.16 节
|
||||
- **问题**:已记录 `getAnnouncementsAction` 使用 `requireAuth()` 而非 `requirePermission()`,但未记录 `app/(dashboard)/announcements/page.tsx` 完全缺少权限校验
|
||||
- **改进建议**:补充已知问题「⚠️ P2:`app/(dashboard)/announcements/page.tsx` 完全缺少权限校验」
|
||||
|
||||
#### DOC-02:management 模块未在架构文档中独立记录
|
||||
- **位置**:004 文档
|
||||
- **问题**:`app/(dashboard)/management/grade/` 路由未在架构文档中记录其依赖关系
|
||||
- **改进建议**:补充 management 路由的模块依赖(classes、school)
|
||||
|
||||
#### DOC-03:settings 模块文件清单过期
|
||||
- **位置**:004 文档 2.23 节
|
||||
- **问题**:记录 `components/* | 8 文件`,但实际有 8 个文件 ✅,需核对行数
|
||||
- **改进建议**:核对并更新各文件行数
|
||||
|
||||
### 5.2 [005_architecture_data.json](../docs/architecture/005_architecture_data.json)
|
||||
|
||||
#### DOC-04:缺少 management 路由记录
|
||||
- **改进建议**:在 `routes` 数组中补充 management 路由
|
||||
|
||||
---
|
||||
|
||||
## 六、问题汇总统计
|
||||
|
||||
| 严重度 | 数量 | 问题编号 |
|
||||
|--------|------|----------|
|
||||
| 高 | 9 | BUG-A01, BUG-M01, BUG-MI01, BUG-P01, BUG-P02, BUG-PC01, BUG-PS01, BUG-AI01, BUG-GC01 |
|
||||
| 中 | 14 | BUG-D01, BUG-MI02, BUG-MI03, BUG-MI04, BUG-MSG02, BUG-S01, BUG-L01, BUG-AL01, BUG-AD01, BUG-ML01, BUG-ML02, BUG-MD01, BUG-MD02, BUG-MC01, BUG-NL01, BUG-NPF01, BUG-AI02 |
|
||||
| 低 | 13 | BUG-A02, BUG-D02, BUG-MI05, BUG-MSG01, BUG-MSG03, BUG-P03, BUG-P04, BUG-P05, BUG-P06, BUG-S02, BUG-SS01, BUG-L02, BUG-E01, BUG-NF01, BUG-AC01, BUG-AD02, BUG-MC02, BUG-MC03, BUG-NL02, BUG-NL03, BUG-PC02, BUG-PC03, BUG-PS02, BUG-PS03, BUG-PS04, BUG-NPF02, BUG-NPF03, BUG-TP01, BUG-TP02, BUG-AI03, BUG-AI04, BUG-AS01, BUG-AS02, BUG-TS01, BUG-ST01, BUG-GC02, BUG-GC03, BUG-GC04 |
|
||||
| 性能 | 9 | PERF-01, PERF-02, PERF-03, PERF-04, PERF-05, PERF-06, PERF-07, PERF-08, PERF-09 |
|
||||
| 界面 | 15 | UI-01 ~ UI-15 |
|
||||
| 文档 | 4 | DOC-01, DOC-02, DOC-03, DOC-04 |
|
||||
| **合计** | **64** | |
|
||||
|
||||
---
|
||||
|
||||
## 七、修复优先级建议
|
||||
|
||||
### P0(立即修复 — 影响安全与正确性)
|
||||
1. **BUG-A01**:`announcements/page.tsx` 增加权限校验
|
||||
2. **BUG-M01**:`management/grade/classes/page.tsx` 增加权限校验
|
||||
3. **BUG-MI01**:`management/grade/insights/page.tsx` 增加权限校验
|
||||
4. **BUG-PC01**:`password-change-form.tsx` 修复动态类名拼接(生产环境进度条无颜色)
|
||||
5. **BUG-PS01**:`profile-settings-form.tsx` 移除 `as any`
|
||||
6. **BUG-AI01**:`ai-provider-settings-card.tsx` 统一 UI 语言为英文
|
||||
|
||||
### P1(本迭代修复 — 影响可维护性与性能)
|
||||
7. **BUG-P01、BUG-S01、BUG-D01**:使用 `roles` 判断角色,移除权限反推
|
||||
8. **BUG-P02**:`profile/page.tsx` 抽取数据加载逻辑到 data-access
|
||||
9. **BUG-MSG02**:`messages/[id]/page.tsx` 使用 `after()` 延迟写操作
|
||||
10. **BUG-AL01、BUG-AD01、BUG-MD01、BUG-MC01**:替换 `<a>` 为 `<Link>`
|
||||
11. **BUG-ML01、BUG-NL01**:使用 `cn()` 替换字符串拼接
|
||||
12. **PERF-01**:`usePermission` 回调 memoize
|
||||
13. **UI-01**:权限相关 UI 改为 RSC prop 传入
|
||||
|
||||
### P2(下迭代修复 — 增强健壮性)
|
||||
14. **BUG-GC01**:`grade-classes-view.tsx` 拆分组件
|
||||
15. **BUG-NPF01**:`notification-preferences-form.tsx` 修复 Switch/checkbox 双重切换
|
||||
16. **BUG-MI02、BUG-MI03、BUG-MI04**:`management/grade/insights` 改用 shadcn Select
|
||||
17. **BUG-PC02**:`password-change-form.tsx` 使用 `useRef` 替代 `document.getElementById`
|
||||
18. **BUG-TS01、BUG-ST01**:抽取 `SettingsLayout` 共享组件
|
||||
19. **BUG-AS01**:修复 Tab 图标语义
|
||||
20. **UI-10**:错误页增加修复步骤
|
||||
|
||||
### P3(文档同步)
|
||||
21. **DOC-01 ~ DOC-04**:同步架构文档
|
||||
|
||||
---
|
||||
|
||||
## 八、验证命令
|
||||
|
||||
修复完成后应运行以下命令确保零错误:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npx tsc --noEmit
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
针对特定模块的端到端验证:
|
||||
|
||||
```bash
|
||||
# 验证权限校验
|
||||
curl -I http://localhost:3000/announcements # 应返回 302 重定向到 /login
|
||||
curl -I http://localhost:3000/management/grade/classes # 应返回 302
|
||||
curl -I http://localhost:3000/management/grade/insights # 应返回 302
|
||||
|
||||
# 验证 hydration
|
||||
# 在浏览器控制台检查无 hydration warning
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> 报告生成人:AI Agent(GLM-5.2)
|
||||
> 核查方法:人工逐行审查 + 架构图比对 + 技能规则匹配
|
||||
> 应用技能:`vercel-react-best-practices`(65 条规则)、`web-design-guidelines`(Web Interface Guidelines)
|
||||
> 注:`web-artifacts-builder` 技能加载失败,界面优化建议已合并至第四章
|
||||
551
bugs/parent_bug.md
Normal file
551
bugs/parent_bug.md
Normal file
@@ -0,0 +1,551 @@
|
||||
# `src/app/(dashboard)/parent` 前端规范核查报告
|
||||
|
||||
> 核查日期:2026-06-18
|
||||
> 核查范围:`src/app/(dashboard)/parent/` 下所有前端文件 + `src/modules/parent/` 配套组件与 data-access
|
||||
> 依据文档:项目规则、编码规范 `docs/standards/coding-standards.md`、架构影响地图 004、架构数据 005
|
||||
> 应用技能:`vercel-react-best-practices`、`web-artifacts-builder`、`web-design-guidelines`
|
||||
|
||||
---
|
||||
|
||||
## 一、核查文件清单
|
||||
|
||||
### 1.1 路由页面文件(`src/app/(dashboard)/parent/`)
|
||||
|
||||
| 文件 | 行数 | 类型 | 用途 |
|
||||
|------|------|------|------|
|
||||
| [dashboard/page.tsx](../src/app/(dashboard)/parent/dashboard/page.tsx) | 16 | Server Component | 家长仪表盘入口页 |
|
||||
| [attendance/page.tsx](../src/app/(dashboard)/parent/attendance/page.tsx) | 61 | Server Component | 子女考勤聚合页 |
|
||||
| [grades/page.tsx](../src/app/(dashboard)/parent/grades/page.tsx) | 61 | Server Component | 子女成绩聚合页 |
|
||||
| [children/[studentId]/page.tsx](../src/app/(dashboard)/parent/children/[studentId]/page.tsx) | 71 | Server Component | 单个子女详情页 |
|
||||
|
||||
### 1.2 模块组件文件(`src/modules/parent/components/`)
|
||||
|
||||
| 文件 | 行数 | 类型 | 用途 |
|
||||
|------|------|------|------|
|
||||
| [parent-dashboard.tsx](../src/modules/parent/components/parent-dashboard.tsx) | 68 | Server Component | 仪表盘主组件 |
|
||||
| [child-card.tsx](../src/modules/parent/components/child-card.tsx) | 89 | Server Component | 子女卡片 |
|
||||
| [child-detail-header.tsx](../src/modules/parent/components/child-detail-header.tsx) | 49 | Server Component | 详情页头部 |
|
||||
| [child-detail-panel.tsx](../src/modules/parent/components/child-detail-panel.tsx) | 27 | Server Component | 详情页面板 |
|
||||
| [child-grade-summary.tsx](../src/modules/parent/components/child-grade-summary.tsx) | 163 | Client Component | 成绩趋势图 |
|
||||
| [child-homework-summary.tsx](../src/modules/parent/components/child-homework-summary.tsx) | 131 | Server Component | 作业摘要 |
|
||||
| [child-schedule-card.tsx](../src/modules/parent/components/child-schedule-card.tsx) | 67 | Server Component | 今日课表 |
|
||||
|
||||
### 1.3 数据访问与类型(`src/modules/parent/`)
|
||||
|
||||
| 文件 | 行数 | 类型 | 用途 |
|
||||
|------|------|------|------|
|
||||
| [data-access.ts](../src/modules/parent/data-access.ts) | 234 | server-only | 家长-子女数据聚合 |
|
||||
| [types.ts](../src/modules/parent/types.ts) | 57 | 类型定义 | 模块类型 |
|
||||
|
||||
---
|
||||
|
||||
## 二、违规问题清单
|
||||
|
||||
### 2.1 `children/[studentId]/page.tsx` — 严重度:高(架构违规)
|
||||
|
||||
#### BUG-P001:app 层直接访问 DB,违反三层架构
|
||||
- **位置**:`src/app/(dashboard)/parent/children/[studentId]/page.tsx:2-6, 24-31`
|
||||
- **问题**:页面直接 `import { db }` 和 `parentStudentRelations` schema,并执行 `db.select().from(parentStudentRelations)` 查询
|
||||
- **规范依据**:项目规则「架构分层规则」明确「`app/` 只能调用 `modules/` 的 Server Actions 和 data-access,不直接访问 DB」
|
||||
- **现状**:
|
||||
```tsx
|
||||
import { db } from "@/shared/db"
|
||||
import { parentStudentRelations } from "@/shared/db/schema"
|
||||
// ...
|
||||
const [relation] = await db
|
||||
.select({ id: parentStudentRelations.id, relation: parentStudentRelations.relation })
|
||||
.from(parentStudentRelations)
|
||||
.where(eq(parentStudentRelations.studentId, studentId))
|
||||
.limit(1)
|
||||
```
|
||||
- **改进建议**:在 `parent/data-access.ts` 新增 `verifyParentChildRelation(studentId, parentId)` 函数,页面调用该函数
|
||||
|
||||
#### BUG-P002:权限校验存在信息泄露风险
|
||||
- **位置**:`src/app/(dashboard)/parent/children/[studentId]/page.tsx:24-31`
|
||||
- **问题**:第一次查询 relation 时仅按 `studentId` 过滤,未加 `parentId = ctx.userId` 条件。任何登录用户都能探测任意 studentId 是否存在 parent 关系
|
||||
- **影响**:信息泄露(可枚举 studentId 探测家庭关系)
|
||||
- **改进建议**:查询条件加 `and(eq(parentStudentRelations.studentId, studentId), eq(parentStudentRelations.parentId, ctx.userId))`
|
||||
|
||||
#### BUG-P003:两个 "Access denied" 分支重复
|
||||
- **位置**:`src/app/(dashboard)/parent/children/[studentId]/page.tsx:33-58`
|
||||
- **问题**:relation 不存在与 dataScope 不包含两个分支返回完全相同的 UI,代码重复
|
||||
- **改进建议**:合并为单一校验路径,或抽取 `AccessDenied` 组件
|
||||
|
||||
#### BUG-P004:`requireAuth()` 未做角色校验
|
||||
- **位置**:`src/app/(dashboard)/parent/children/[studentId]/page.tsx:21`
|
||||
- **问题**:使用 `requireAuth()` 而非 `requirePermission()`,未校验当前用户是否为 parent 角色。teacher/admin 也能访问该页面(虽然 dataScope 校验会拦截,但应前置失败)
|
||||
- **改进建议**:使用 `requirePermission(PARENT_VIEW)` 或在 auth-guard 中增加角色校验
|
||||
|
||||
---
|
||||
|
||||
### 2.2 `attendance/page.tsx` 与 `grades/page.tsx` — 严重度:高(代码重复)
|
||||
|
||||
#### BUG-P005:两个页面文件几乎完全重复
|
||||
- **位置**:`src/app/(dashboard)/parent/attendance/page.tsx` 与 `src/app/(dashboard)/parent/grades/page.tsx`
|
||||
- **问题**:两个文件结构 95% 相同,仅模块名(attendance vs grades)、图标(CalendarCheck vs GraduationCap)、标题文案不同
|
||||
- **违反规则**:DRY 原则,编码规范「工具函数 ≤ 40 行」隐含的复用精神
|
||||
- **改进建议**:抽取共享组件 `ParentChildrenDataPage`,通过 props 传入 `fetcher`、`icon`、`title`、`emptyTitle`、`renderItem`
|
||||
|
||||
```tsx
|
||||
// 抽取后的共享组件
|
||||
function ParentChildrenDataPage<T>({
|
||||
title, description, icon, fetcher, renderItem, emptyTitle, emptyDescription,
|
||||
}: ParentChildrenDataPageProps<T>) { /* ... */ }
|
||||
```
|
||||
|
||||
#### BUG-P006:`Promise.all` 内部异常未处理
|
||||
- **位置**:`src/app/(dashboard)/parent/attendance/page.tsx:29-31`、`grades/page.tsx:29-31`
|
||||
- **问题**:`ctx.dataScope.childrenIds.map((id) => getStudentAttendanceSummary(id))` 若任一查询抛错,整个页面 500。未做 try-catch 或 Promise.allSettled
|
||||
- **改进建议**:使用 `Promise.allSettled` 并过滤 rejected,或对单个子女查询失败显示局部错误状态
|
||||
|
||||
---
|
||||
|
||||
### 2.3 `dashboard/page.tsx` — 严重度:中
|
||||
|
||||
#### BUG-P007:缺少 dataScope 空状态处理
|
||||
- **位置**:`src/app/(dashboard)/parent/dashboard/page.tsx:7-15`
|
||||
- **问题**:未检查 `ctx.dataScope.type === "children"` 或 `childrenIds.length === 0`,直接调用 `getParentDashboardData(ctx.userId)`。虽然 data-access 会返回空数组,但与 attendance/grades 页面的处理方式不一致
|
||||
- **改进建议**:与 attendance/grades 页面统一,前置检查 dataScope
|
||||
|
||||
---
|
||||
|
||||
### 2.4 `parent-dashboard.tsx` — 严重度:中
|
||||
|
||||
#### BUG-P008:使用 `<a href>` 而非 `<Link>`,丢失客户端导航
|
||||
- **位置**:`src/modules/parent/components/parent-dashboard.tsx:30, 36`
|
||||
- **问题**:`<a href="/parent/grades">` 和 `<a href="/announcements">` 使用原生 `<a>` 标签,导致整页刷新,丢失 Next.js 客户端路由优化
|
||||
- **违反规则**:Web Interface Guidelines — Navigation & State「Links use `<a>`/`<Link>` (Cmd/Ctrl+click, middle-click support)」;Next.js 最佳实践
|
||||
- **改进建议**:`import Link from "next/link"`,使用 `<Link href="/parent/grades">`
|
||||
|
||||
#### BUG-P009:问候语使用 `new Date().getHours()` 存在时区风险
|
||||
- **位置**:`src/modules/parent/components/parent-dashboard.tsx:12-16`
|
||||
- **问题**:服务端渲染时使用服务器时区计算问候语,与用户实际时区可能不符(例如部署在 UTC 服务器,北京时间早 8 点用户看到 "Good afternoon")
|
||||
- **违反规则**:Web Interface Guidelines — Locale & i18n「Dates/times: use `Intl.DateTimeFormat` not hardcoded formats」
|
||||
- **改进建议**:使用 `Intl.DateTimeFormat(undefined, { hour: "numeric", timeZone: ctx.timezone })` 或将问候语计算移至客户端组件
|
||||
|
||||
#### BUG-P010:标题层级与间距与其他页面不一致
|
||||
- **位置**:`src/modules/parent/components/parent-dashboard.tsx:22` vs `attendance/page.tsx:16`、`grades/page.tsx::16`
|
||||
- **问题**:dashboard 使用 `text-3xl` + `space-y-6`,attendance/grades 使用 `text-2xl` + `space-y-8`
|
||||
- **违反规则**:web-artifacts-builder 设计一致性原则
|
||||
- **改进建议**:统一标题字号(建议 `text-2xl`)和间距(建议 `space-y-6`)
|
||||
|
||||
---
|
||||
|
||||
### 2.5 `child-card.tsx` — 严重度:中
|
||||
|
||||
#### BUG-P011:`getInitials` 函数重复定义
|
||||
- **位置**:`src/modules/parent/components/child-card.tsx:9-12` 与 `src/modules/parent/components/child-detail-header.tsx:9-12`
|
||||
- **问题**:两个文件定义了完全相同的 `getInitials` 函数
|
||||
- **违反规则**:DRY 原则
|
||||
- **改进建议**:抽取到 `src/modules/parent/lib/utils.ts` 或 `src/shared/lib/utils.ts`
|
||||
|
||||
#### BUG-P012:使用字符串拼接动态类名,违反 Tailwind 规范
|
||||
- **位置**:`src/modules/parent/components/child-card.tsx:57-60`
|
||||
- **问题**:
|
||||
```tsx
|
||||
className={`text-lg font-semibold tabular-nums ${
|
||||
homeworkSummary.overdueCount > 0 ? "text-destructive" : ""
|
||||
}`}
|
||||
```
|
||||
使用模板字符串拼接类名,违反编码规范「Tailwind 规范:使用 `cn()` 工具函数管理条件类名」
|
||||
- **对比**:同模块 `child-homework-summary.tsx:72-75` 正确使用了 `cn()`
|
||||
- **改进建议**:
|
||||
```tsx
|
||||
className={cn(
|
||||
"text-lg font-semibold tabular-nums",
|
||||
homeworkSummary.overdueCount > 0 && "text-destructive",
|
||||
)}
|
||||
```
|
||||
|
||||
#### BUG-P013:手动截断标题,应使用 Tailwind `truncate`/`line-clamp`
|
||||
- **位置**:`src/modules/parent/components/child-card.tsx:81-83`
|
||||
- **问题**:
|
||||
```tsx
|
||||
({latestGrade.assignmentTitle.slice(0, 20)}
|
||||
{latestGrade.assignmentTitle.length > 20 ? "..." : ""})
|
||||
```
|
||||
手动 slice + "..." 截断,违反 Web Interface Guidelines — Typography「`…` not `...`」
|
||||
- **改进建议**:使用 `<span className="truncate inline-block max-w-[200px]">{latestGrade.assignmentTitle}</span>`
|
||||
|
||||
#### BUG-P014:`cursor-pointer` 在 Link 上冗余
|
||||
- **位置**:`src/modules/parent/components/child-card.tsx:21`
|
||||
- **问题**:`<Card className="hover:bg-muted/50 transition-colors cursor-pointer h-full">` 中 `cursor-pointer` 冗余(外层 `<Link>` 默认 pointer)
|
||||
- **违反规则**:Web Interface Guidelines — Anti-patterns
|
||||
- **改进建议**:移除 `cursor-pointer`
|
||||
|
||||
#### BUG-P015:整个 Card 作为 Link 缺少可访问性描述
|
||||
- **位置**:`src/modules/parent/components/child-card.tsx:20-87`
|
||||
- **问题**:`<Link>` 包裹整个 Card,屏幕阅读器会读出所有内部文本(姓名、班级、数字、最新成绩),缺少简洁的 aria-label
|
||||
- **违反规则**:Web Interface Guidelines — Accessibility「Icon-only buttons need `aria-label`」延伸到卡片导航
|
||||
- **改进建议**:`<Link href={...} aria-label={`查看 ${basicInfo.name ?? "子女"} 的详情`}>`
|
||||
|
||||
#### BUG-P016:Link 缺少 `focus-visible:ring` 样式
|
||||
- **位置**:`src/modules/parent/components/child-card.tsx:20-21`
|
||||
- **问题**:`<Link>` 包裹 Card,但 Card 没有 `focus-visible:ring-*` 样式,键盘导航时无可见焦点
|
||||
- **违反规则**:Web Interface Guidelines — Focus States「Interactive elements need visible focus」
|
||||
- **改进建议**:添加 `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`
|
||||
|
||||
---
|
||||
|
||||
### 2.6 `child-detail-header.tsx` — 严重度:低
|
||||
|
||||
#### BUG-P017:`getInitials` 重复(同 BUG-P011)
|
||||
- **位置**:`src/modules/parent/components/child-detail-header.tsx:9-12`
|
||||
- **改进建议**:见 BUG-P011
|
||||
|
||||
#### BUG-P018:邮箱直接展示未做防爬处理
|
||||
- **位置**:`src/modules/parent/components/child-detail-header.tsx:43`
|
||||
- **问题**:`<span>· {basicInfo.email}</span>` 直接展示子女邮箱,无防爬/掩码处理
|
||||
- **改进建议**:考虑隐私场景下掩码处理(如 `j***@example.com`),或仅对家长本人可见时展示完整
|
||||
|
||||
---
|
||||
|
||||
### 2.7 `child-grade-summary.tsx` — 严重度:中
|
||||
|
||||
#### BUG-P019:`"use client"` 必要性可优化
|
||||
- **位置**:`src/modules/parent/components/child-grade-summary.tsx:1`
|
||||
- **问题**:组件标记为 `"use client"`,但实际仅 `recharts` 需要客户端。整个组件(包括数据预处理 `chartData` 计算)都被打包到客户端 bundle
|
||||
- **违反规则**:`vercel-react-best-practices` — `bundle-dynamic-imports`「Use next/dynamic for heavy components」
|
||||
- **改进建议**:将图表部分抽取为独立的客户端组件 `GradeTrendChart`,父组件保持服务端组件,通过 `next/dynamic` 懒加载图表
|
||||
|
||||
#### BUG-P020:`latestGrade` 取数组末尾,语义不明确
|
||||
- **位置**:`src/modules/parent/components/child-grade-summary.tsx:32`
|
||||
- **问题**:`const latestGrade = grades.trend[grades.trend.length - 1]` 假设 trend 是按时间升序排列,但类型定义 `StudentDashboardGradeProps` 未明确顺序
|
||||
- **对比**:`child-card.tsx:17` 使用 `gradeTrend.recent[0]` 取最新(假设 recent 是降序)
|
||||
- **改进建议**:在 `homework/types.ts` 的 `StudentDashboardGradeProps` 中补充 JSDoc 说明 `trend` 和 `recent` 的排序语义
|
||||
|
||||
#### BUG-P021:`chartData` 在每次渲染时重新计算
|
||||
- **位置**:`src/modules/parent/components/child-grade-summary.tsx:34-41`
|
||||
- **问题**:`const chartData = grades.trend.map(...)` 每次渲染都重新 map,未 memoize
|
||||
- **违反规则**:`vercel-react-best-practices` — `rerender-memo`「Extract expensive work into memoized components」
|
||||
- **改进建议**:`const chartData = useMemo(() => grades.trend.map(...), [grades.trend])`
|
||||
|
||||
#### BUG-P022:`tickFormatter` 内联函数每次渲染创建新引用
|
||||
- **位置**:`src/modules/parent/components/child-grade-summary.tsx:99-101`
|
||||
- **问题**:`tickFormatter={(value) => value.slice(0, 8) + (value.length > 8 ? "..." : "")}` 内联箭头函数
|
||||
- **违反规则**:Web Interface Guidelines — Typography「`…` not `...`」;`vercel-react-best-practices` — `rerender-functional-setstate`
|
||||
- **改进建议**:抽取为模块级纯函数 `const formatTick = (v: string) => v.slice(0, 8) + (v.length > 8 ? "…" : "")`
|
||||
|
||||
#### BUG-P023:使用 `"..."` 应为 `…`
|
||||
- **位置**:`src/modules/parent/components/child-grade-summary.tsx:100`
|
||||
- **问题**:`value.length > 8 ? "..." : ""` 使用三个英文句号,应为省略号字符 `…`
|
||||
- **违反规则**:Web Interface Guidelines — Typography「`…` not `...`」
|
||||
|
||||
---
|
||||
|
||||
### 2.8 `child-homework-summary.tsx` — 严重度:低
|
||||
|
||||
#### BUG-P024:状态字符串硬编码,应使用枚举/常量
|
||||
- **位置**:`src/modules/parent/components/child-homework-summary.tsx:10-21`
|
||||
- **问题**:`getStatusVariant` 和 `getStatusLabel` 使用硬编码字符串 `"graded"`、`"submitted"`、`"in_progress"` 比较
|
||||
- **改进建议**:从 `homework/types.ts` 导入状态常量或联合类型,使用 switch + exhaustive check
|
||||
|
||||
#### BUG-P025:`getDueUrgency` 在渲染期调用 `new Date()`
|
||||
- **位置**:`src/modules/parent/components/child-homework-summary.tsx:23-31, 95`
|
||||
- **问题**:每次 `map` 迭代都调用 `new Date()` 创建新日期对象,虽然性能影响小,但语义上应在外层计算一次 `now`
|
||||
- **改进建议**:在组件顶部 `const now = new Date()` 一次,传入 `getDueUrgency(a.dueAt, now)`
|
||||
|
||||
---
|
||||
|
||||
### 2.9 `child-schedule-card.tsx` — 严重度:低
|
||||
|
||||
#### BUG-P026:空状态高度与其他组件不一致
|
||||
- **位置**:`src/modules/parent/components/child-schedule-card.tsx:31`
|
||||
- **问题**:`className="border-none h-60"`,而 `child-grade-summary.tsx:57` 使用 `h-60`,`child-homework-summary.tsx:87` 使用 `h-40`
|
||||
- **改进建议**:统一空状态高度(建议 `h-48`)
|
||||
|
||||
---
|
||||
|
||||
### 2.10 `data-access.ts` — 严重度:中
|
||||
|
||||
#### BUG-P027:`toWeekday` 使用 `as` 类型断言
|
||||
- **位置**:`src/modules/parent/data-access.ts:28-31`
|
||||
- **问题**:
|
||||
```ts
|
||||
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
||||
const day = d.getDay()
|
||||
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
}
|
||||
```
|
||||
使用 `as` 类型断言,违反编码规范「禁止 `as` 断言(除非从 `unknown` 转换)」
|
||||
- **改进建议**:使用类型守卫
|
||||
```ts
|
||||
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
||||
const day = d.getDay()
|
||||
const weekday = day === 0 ? 7 : day
|
||||
if (weekday < 1 || weekday > 7) throw new Error(`Invalid weekday: ${weekday}`)
|
||||
return weekday
|
||||
}
|
||||
```
|
||||
|
||||
#### BUG-P028:`getChildBasicInfo` 串行查询瀑布(架构图已标注 P2)
|
||||
- **位置**:`src/modules/parent/data-access.ts:58-117`
|
||||
- **问题**:4 个串行 DB 查询:users → grades → classEnrollments → classes。其中 grades 和 classEnrollments 互相独立,可并行
|
||||
- **违反规则**:`vercel-react-best-practices` — `async-parallel`「Use Promise.all() for independent operations」
|
||||
- **架构图标注**:004 文档 2.19 节「⚠️ P2:`getChildBasicInfo` 多次串行查询,可优化为 join」
|
||||
- **改进建议**:
|
||||
```ts
|
||||
// grades 和 classEnrollments 并行
|
||||
const [gradeRow, enrollment] = await Promise.all([
|
||||
student.gradeId
|
||||
? db.select({ name: grades.name }).from(grades).where(eq(grades.id, student.gradeId)).limit(1)
|
||||
: Promise.resolve([]),
|
||||
db.select({ classId: classEnrollments.classId, status: classEnrollments.status })
|
||||
.from(classEnrollments)
|
||||
.where(and(eq(classEnrollments.studentId, studentId), eq(classEnrollments.status, "active")))
|
||||
.orderBy(asc(classEnrollments.createdAt))
|
||||
.limit(1),
|
||||
])
|
||||
```
|
||||
或重构为单次 JOIN 查询
|
||||
|
||||
#### BUG-P029:`getChildBasicInfo` 返回类型未显式标注
|
||||
- **位置**:`src/modules/parent/data-access.ts:58`
|
||||
- **问题**:`export const getChildBasicInfo = cache(async (studentId: string, relation: string | null = null) => {` 未显式标注返回类型
|
||||
- **违反规则**:编码规范「函数返回值必须显式标注,特别是 `Promise<T>`」
|
||||
- **改进建议**:定义 `ChildBasicInfo` 返回类型并显式标注(`types.ts` 中已有 `ChildBasicInfo` 类型,可直接使用)
|
||||
|
||||
#### BUG-P030:`buildHomeworkSummary` 中 `[...assignments].sort()` 不必要的拷贝
|
||||
- **位置**:`src/modules/parent/data-access.ts:150-156`
|
||||
- **问题**:`[...assignments].sort(...)` 创建数组副本再排序。`assignments` 来自 `getStudentHomeworkAssignments` 返回的新数组,无需再拷贝
|
||||
- **改进建议**:直接 `assignments.sort(...)` 或使用 `toSorted()`(ES2023)
|
||||
|
||||
---
|
||||
|
||||
### 2.11 `types.ts` — 严重度:低
|
||||
|
||||
#### BUG-P031:类型缺少 JSDoc 文档注释
|
||||
- **位置**:`src/modules/parent/types.ts` 全文件
|
||||
- **问题**:所有类型(`ParentChildRelation`、`ChildBasicInfo`、`ChildScheduleItem`、`ChildHomeworkSummary`、`ChildDashboardData`、`ParentDashboardData`)均无 JSDoc
|
||||
- **违反规则**:编码规范 5.4「必须编写 JSDoc」
|
||||
- **改进建议**:为每个类型补充 JSDoc,说明用途、字段语义
|
||||
|
||||
#### BUG-P032:`ChildHomeworkSummary` 与组件同名类型冲突风险
|
||||
- **位置**:`src/modules/parent/types.ts:37` 与 `child-homework-summary.tsx:33`
|
||||
- **问题**:类型名 `ChildHomeworkSummary` 与组件名 `ChildHomeworkSummary` 完全相同,在 `child-homework-summary.tsx` 中同时 import 两者会造成命名冲突
|
||||
```tsx
|
||||
import type { ChildHomeworkSummary } from "@/modules/parent/types" // 类型
|
||||
export function ChildHomeworkSummary({ summary }: { summary: ChildHomeworkSummary }) // 组件
|
||||
```
|
||||
当前依赖 TypeScript 类型与值的命名空间分离才不冲突,但可读性差
|
||||
- **改进建议**:类型重命名为 `ChildHomeworkSummaryData` 或组件重命名为 `ChildHomeworkSummaryCard`
|
||||
|
||||
---
|
||||
|
||||
## 三、React 性能优化(应用 `vercel-react-best-practices` 技能)
|
||||
|
||||
### PERF-P01:`getChildBasicInfo` 串行查询瀑布(同 BUG-P028)
|
||||
- **违反规则**:`async-parallel` — Use Promise.all() for independent operations
|
||||
- **改进建议**:见 BUG-P028
|
||||
|
||||
### PERF-P02:`chartData` 未 memoize(同 BUG-P021)
|
||||
- **违反规则**:`rerender-memo` — Extract expensive work into memoized components
|
||||
- **改进建议**:见 BUG-P021
|
||||
|
||||
### PERF-P03:`child-grade-summary.tsx` 整体客户端化,bundle 体积大
|
||||
- **位置**:`src/modules/parent/components/child-grade-summary.tsx:1`
|
||||
- **问题**:`"use client"` 导致 recharts(~100KB)整体进入客户端 bundle,但组件大部分逻辑(数据预处理、布局)可在服务端完成
|
||||
- **违反规则**:`bundle-dynamic-imports` — Use next/dynamic for heavy components
|
||||
- **改进建议**:
|
||||
```tsx
|
||||
// child-grade-summary.tsx (Server Component)
|
||||
import dynamic from "next/dynamic"
|
||||
const GradeTrendChart = dynamic(() => import("./grade-trend-chart").then(m => m.GradeTrendChart))
|
||||
// 仅图表部分客户端化
|
||||
```
|
||||
|
||||
### PERF-P04:`getParentDashboardData` 内部 `Promise.all` 已正确并行化
|
||||
- **位置**:`src/modules/parent/data-access.ts:225-227`
|
||||
- **说明**:✅ 已正确使用 `Promise.all` 并行获取所有子女数据,符合 `async-parallel` 规范
|
||||
|
||||
### PERF-P05:`getChildDashboardData` 内部 `Promise.all` 已正确并行化
|
||||
- **位置**:`src/modules/parent/data-access.ts:190-196`
|
||||
- **说明**:✅ 已正确使用 `Promise.all` 并行获取 enrolledClasses/schedule/assignments/gradeTrend/gradeSummary
|
||||
|
||||
### PERF-P06:`cache()` 已正确包裹 data-access 函数
|
||||
- **位置**:`src/modules/parent/data-access.ts:33, 58, 185, 209`
|
||||
- **说明**:✅ 所有 data-access 函数均使用 React `cache()` 包裹,符合 `server-cache-react` 规范,实现单次请求内去重
|
||||
|
||||
### PERF-P07:`parent-dashboard.tsx` 中 `new Date()` 在服务端执行无 hydration 风险
|
||||
- **位置**:`src/modules/parent/components/parent-dashboard.tsx:12`
|
||||
- **说明**:✅ 组件为 Server Component,`new Date()` 仅在服务端执行一次,无 hydration mismatch 风险(但有时区问题,见 BUG-P009)
|
||||
|
||||
---
|
||||
|
||||
## 四、Web 界面规范审查(应用 `web-design-guidelines` 技能)
|
||||
|
||||
### UI-P01:`parent-dashboard.tsx`
|
||||
|
||||
```
|
||||
src/modules/parent/components/parent-dashboard.tsx:30 - <a href> → use <Link> for client-side nav
|
||||
src/modules/parent/components/parent-dashboard.tsx:36 - <a href> → use <Link> for client-side nav
|
||||
src/modules/parent/components/parent-dashboard.tsx:12 - new Date() server-side, timezone mismatch risk
|
||||
src/modules/parent/components/parent-dashboard.tsx:22 - title size inconsistent (text-3xl vs text-2xl in other pages)
|
||||
```
|
||||
|
||||
### UI-P02:`child-card.tsx`
|
||||
|
||||
```
|
||||
src/modules/parent/components/child-card.tsx:20 - Link wrapping Card lacks aria-label
|
||||
src/modules/parent/components/child-card.tsx:21 - cursor-pointer redundant on Link
|
||||
src/modules/parent/components/child-card.tsx:21 - missing focus-visible:ring-* for keyboard nav
|
||||
src/modules/parent/components/child-card.tsx:57 - string concatenation for className → use cn()
|
||||
src/modules/parent/components/child-card.tsx:82 - "..." → "…"
|
||||
src/modules/parent/components/child-card.tsx:82 - manual slice truncation → use truncate/line-clamp
|
||||
```
|
||||
|
||||
### UI-P03:`child-grade-summary.tsx`
|
||||
|
||||
```
|
||||
src/modules/parent/components/child-grade-summary.tsx:100 - "..." → "…"
|
||||
src/modules/parent/components/child-grade-summary.tsx:99 - inline tickFormatter → hoist to module scope
|
||||
src/modules/parent/components/child-grade-summary.tsx:142 - Link lacks query param for tab deep-linking
|
||||
```
|
||||
|
||||
### UI-P04:`child-homework-summary.tsx`
|
||||
|
||||
```
|
||||
src/modules/parent/components/child-homework-summary.tsx:98 - Link lacks query param for tab deep-linking
|
||||
src/modules/parent/components/child-homework-summary.tsx:25 - new Date() in each map iteration → hoist to component scope
|
||||
```
|
||||
|
||||
### UI-P05:`child-detail-header.tsx`
|
||||
|
||||
```
|
||||
src/modules/parent/components/child-detail-header.tsx:43 - email displayed without masking (privacy)
|
||||
```
|
||||
|
||||
### UI-P06:`attendance/page.tsx` & `grades/page.tsx`
|
||||
|
||||
```
|
||||
src/app/(dashboard)/parent/attendance/page.tsx:14 - h-full flex-1 flex-col space-y-8 p-8 md:flex → inconsistent with dashboard/page.tsx (p-6 md:p-8)
|
||||
src/app/(dashboard)/parent/grades/page.tsx:14 - same inconsistency as above
|
||||
```
|
||||
|
||||
### UI-P07:空状态一致性
|
||||
|
||||
```
|
||||
src/modules/parent/components/child-schedule-card.tsx:31 - EmptyState h-60
|
||||
src/modules/parent/components/child-grade-summary.tsx:57 - EmptyState h-60
|
||||
src/modules/parent/components/child-homework-summary.tsx:87 - EmptyState h-40
|
||||
src/app/(dashboard)/parent/attendance/page.tsx:24 - EmptyState border-none shadow-none (no height)
|
||||
→ unify EmptyState height and className
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、界面优化建议(应用 `web-artifacts-builder` 技能)
|
||||
|
||||
### UIX-P01:子女卡片网格响应式断点不足
|
||||
- **位置**:`src/modules/parent/components/parent-dashboard.tsx:59`
|
||||
- **问题**:`grid-cols-1 md:grid-cols-2 lg:grid-cols-3` 在 sm 屏幕下强制单列,2 列布局在 sm(640px)下更合适
|
||||
- **改进建议**:`grid-cols-1 sm:grid-cols-2 lg:grid-cols-3`
|
||||
|
||||
### UIX-P02:详情页布局中等屏幕下右侧栏过窄
|
||||
- **位置**:`src/modules/parent/components/child-detail-panel.tsx:12`
|
||||
- **问题**:`grid-cols-1 lg:grid-cols-3` 在 md(768-1024px)下为单列,但 `lg:col-span-2` 在 lg 下才生效,md 下左侧内容占满,右侧课表也在下方
|
||||
- **改进建议**:增加 md 断点 `grid-cols-1 md:grid-cols-2 lg:grid-cols-3`,左侧 `md:col-span-1 lg:col-span-2`
|
||||
|
||||
### UIX-P03:卡片内嵌套卡片视觉层级混乱
|
||||
- **位置**:`src/modules/parent/components/child-card.tsx:43-73`
|
||||
- **问题**:Card 内部 CardContent 中又使用 `rounded-md border bg-card p-2` 创建 3 个小卡片,与外层 Card 视觉层级冲突
|
||||
- **改进建议**:内部小卡片改用 `bg-muted/50` 或移除 border,弱化层级
|
||||
|
||||
### UIX-P04:作业摘要卡片缺少"查看全部"链接
|
||||
- **位置**:`src/modules/parent/components/child-homework-summary.tsx:90-126`
|
||||
- **问题**:仅展示 `recentAssignments`(最多 5 条),无"查看全部作业"入口
|
||||
- **改进建议**:底部添加 `<Link href="/parent/children/{childId}?tab=homework">View all</Link>`
|
||||
|
||||
### UIX-P05:成绩趋势图 X 轴标签截断后信息丢失
|
||||
- **位置**:`src/modules/parent/components/child-grade-summary.tsx:94-102`
|
||||
- **问题**:`tickFormatter` 截断为 8 字符 + "…",多个作业标题前 8 字符相同时无法区分
|
||||
- **改进建议**:X 轴改为日期(`formatDate(submittedAt)`),标题在 tooltip 中完整展示
|
||||
|
||||
### UIX-P06:仪表盘快捷入口仅 2 个,可扩展
|
||||
- **位置**:`src/modules/parent/components/parent-dashboard.tsx:28-41`
|
||||
- **问题**:仅有 Grades 和 Announcements 两个快捷按钮,缺少 Attendance、Schedule 等常用入口
|
||||
- **改进建议**:增加 Attendance 快捷入口,或改为下拉菜单
|
||||
|
||||
---
|
||||
|
||||
## 六、架构文档同步问题
|
||||
|
||||
### DOC-P01:004 文档 parent 模块行数记录过期
|
||||
- **位置**:`docs/architecture/004_architecture_impact_map.md:924`
|
||||
- **问题**:记录 `data-access.ts | 234 | 子女关系 + 仪表盘数据聚合`,实际 234 行 ✅ 一致;但 `components/* | 7 文件` 实际为 7 个组件文件 ✅ 一致
|
||||
- **说明**:本节核查后无需更新(行数与文件数均一致)
|
||||
|
||||
### DOC-P02:004 文档未记录 `children/[studentId]/page.tsx` 的架构违规
|
||||
- **位置**:`docs/architecture/004_architecture_impact_map.md` 2.19 节
|
||||
- **问题**:未在 parent 模块「已知问题」中记录 `app/(dashboard)/parent/children/[studentId]/page.tsx` 直接访问 DB 的违规(BUG-P001)
|
||||
- **改进建议**:在 004 文档 2.19 节「已知问题」中补充:
|
||||
```
|
||||
- ❌ P1:`app/(dashboard)/parent/children/[studentId]/page.tsx` 直接访问 DB(违反三层架构)
|
||||
```
|
||||
|
||||
### DOC-P03:005 JSON 中 parent 模块的 routes 节点需补充
|
||||
- **问题**:若修复 BUG-P005(抽取共享组件),路由结构不变,但需在 005 JSON 中记录 attendance/grades 页面的 fetcher 依赖关系
|
||||
- **改进建议**:在 `005_architecture_data.json` 的 `modules.parent.dependencies` 中补充 `attendance`、`grades` 模块依赖
|
||||
|
||||
---
|
||||
|
||||
## 七、问题汇总统计
|
||||
|
||||
| 严重度 | 数量 | 问题编号 |
|
||||
|--------|------|----------|
|
||||
| 高(架构违规/安全) | 6 | BUG-P001, BUG-P002, BUG-P004, BUG-P005, BUG-P006, BUG-P028 |
|
||||
| 中(规范违规/性能) | 12 | BUG-P003, BUG-P007, BUG-P008, BUG-P009, BUG-P010, BUG-P011, BUG-P012, BUG-P019, BUG-P020, BUG-P021, BUG-P027, BUG-P029 |
|
||||
| 低(代码质量/UX) | 13 | BUG-P013, BUG-P014, BUG-P015, BUG-P016, BUG-P017, BUG-P018, BUG-P022, BUG-P023, BUG-P024, BUG-P025, BUG-P026, BUG-P030, BUG-P031, BUG-P032 |
|
||||
| 合计 | 31 | — |
|
||||
|
||||
### 按技能分类统计
|
||||
|
||||
| 技能 | 发现问题数 | 主要问题类型 |
|
||||
|------|-----------|-------------|
|
||||
| 项目规范核查 | 18 | 架构违规、代码重复、类型规范、Tailwind 规范 |
|
||||
| vercel-react-best-practices | 7 | 串行查询瀑布、bundle 体积、memoize 缺失 |
|
||||
| web-design-guidelines | 15 | 可访问性、焦点状态、排版、导航、空状态一致性 |
|
||||
| web-artifacts-builder | 6 | 响应式断点、视觉层级、交互入口、图表可读性 |
|
||||
|
||||
---
|
||||
|
||||
## 八、修复优先级建议
|
||||
|
||||
### P0(立即修复 — 架构与安全)
|
||||
1. **BUG-P001**:`children/[studentId]/page.tsx` 移除直接 DB 访问,下沉到 `parent/data-access.ts`
|
||||
2. **BUG-P002**:权限校验加 `parentId` 条件,防止信息泄露
|
||||
3. **BUG-P005**:抽取 `ParentChildrenDataPage` 共享组件,消除 attendance/grades 重复
|
||||
|
||||
### P1(短期修复 — 规范与性能)
|
||||
4. **BUG-P008**:`<a href>` 改为 `<Link>`
|
||||
5. **BUG-P012**:`child-card.tsx` 使用 `cn()` 替代字符串拼接
|
||||
6. **BUG-P011**:抽取 `getInitials` 到共享 utils
|
||||
7. **BUG-P028**:`getChildBasicInfo` 并行化查询
|
||||
8. **BUG-P019**:`child-grade-summary.tsx` 拆分服务端/客户端组件
|
||||
9. **BUG-P027**:`toWeekday` 移除 `as` 断言
|
||||
10. **BUG-P029**:`getChildBasicInfo` 显式标注返回类型
|
||||
|
||||
### P2(机会修复 — UX 与代码质量)
|
||||
11. **BUG-P009**:问候语时区处理
|
||||
12. **BUG-P013**:使用 `truncate` 替代手动截断
|
||||
13. **BUG-P015, BUG-P016**:卡片可访问性增强
|
||||
14. **BUG-P023**:`...` → `…`
|
||||
15. **BUG-P031**:补充类型 JSDoc
|
||||
16. **UIX-P01~P06**:界面优化项
|
||||
|
||||
---
|
||||
|
||||
## 九、标杆实践(建议保留)
|
||||
|
||||
| 实践 | 位置 | 说明 |
|
||||
|------|------|------|
|
||||
| `cache()` 包裹 data-access | `data-access.ts:33, 58, 185, 209` | 符合 `server-cache-react`,单次请求去重 |
|
||||
| `Promise.all` 并行获取子女数据 | `data-access.ts:190-196, 225-227` | 符合 `async-parallel`,消除瀑布 |
|
||||
| Server Component 默认 | 7/8 组件为 Server Component | 仅 `child-grade-summary.tsx` 因 recharts 标记 client |
|
||||
| `import type` 正确使用 | 所有类型导入均使用 `import type` | 符合编码规范 4.2.6 |
|
||||
| `server-only` 标注 | `data-access.ts:1` | 防止 data-access 被客户端误引入 |
|
||||
| 空状态处理完整 | 所有页面均使用 `EmptyState` 组件 | UX 一致性良好 |
|
||||
|
||||
---
|
||||
|
||||
> **说明**:本报告基于 2026-06-18 代码状态生成。修复后需同步更新 `docs/architecture/004_architecture_impact_map.md` 2.19 节与 `005_architecture_data.json` 的 parent 模块节点。
|
||||
297
bugs/shared_bug.md
Normal file
297
bugs/shared_bug.md
Normal file
@@ -0,0 +1,297 @@
|
||||
# `src/shared/types` 规范核查报告
|
||||
|
||||
> 核查日期:2026-06-18
|
||||
> 核查范围:`src/shared/types/` 目录下所有前后端文件
|
||||
> 依据文档:项目规则、编码规范、架构影响地图 004、架构数据 005
|
||||
> 应用技能:`vercel-react-best-practices`、`web-artifacts-builder`、`web-design-guidelines`
|
||||
|
||||
---
|
||||
|
||||
## 一、核查文件清单
|
||||
|
||||
| 文件 | 行数 | 类型 | 用途 |
|
||||
|------|------|------|------|
|
||||
| [action-state.ts](../src/shared/types/action-state.ts) | 5 | 类型定义 | Server Action 统一返回类型 |
|
||||
| [action-state.test.ts](../src/shared/types/action-state.test.ts) | 33 | 单元测试 | ActionState 类型测试 |
|
||||
| [permissions.ts](../src/shared/types/permissions.ts) | 114 | 类型定义+常量 | 权限点常量、Permission/DataScope/AuthContext 类型 |
|
||||
|
||||
---
|
||||
|
||||
## 二、违规问题清单
|
||||
|
||||
### 2.1 action-state.ts — 严重度:高
|
||||
|
||||
#### BUG-A01:Prettier 配置违规(使用分号)
|
||||
- **位置**:`src/shared/types/action-state.ts:1-5`
|
||||
- **问题**:文件使用分号(`;`)结尾,但项目 `.prettierrc` 配置 `"semi": false`,应移除所有分号
|
||||
- **现状**:
|
||||
```typescript
|
||||
export type ActionState<T = void> = {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
errors?: Record<string, string[]>;
|
||||
data?: T;
|
||||
};
|
||||
```
|
||||
- **改进建议**:移除所有分号,与 `permissions.ts`、`action-state.test.ts` 保持一致
|
||||
|
||||
#### BUG-A02:缺少 JSDoc 文档注释
|
||||
- **位置**:`src/shared/types/action-state.ts:1`
|
||||
- **问题**:`ActionState<T>` 类型缺少 JSDoc 注释,未说明类型用途、泛型参数、各字段含义
|
||||
- **规范依据**:编码规范 5.4「必须编写 JSDoc」
|
||||
- **改进建议**:补充类型级 JSDoc,说明 `@template T`、各 property 语义
|
||||
|
||||
---
|
||||
|
||||
### 2.2 permissions.ts — 严重度:高
|
||||
|
||||
#### BUG-P01:权限点命名不一致(下划线 vs 驼峰)
|
||||
- **位置**:`src/shared/types/permissions.ts:91`
|
||||
- **问题**:`EXAM_PROCTOR_READ: "exam:proctor_read"` 使用下划线分隔,而其他 READ 权限均使用单词形式(如 `exam:read`、`question:read`)
|
||||
- **改进建议**:统一为 `exam:proctor:read`(嵌套资源用冒号分隔)
|
||||
|
||||
#### BUG-P02:`Permissions` 常量缺少 `satisfies` 类型约束
|
||||
- **位置**:`src/shared/types/permissions.ts:4-96`
|
||||
- **问题**:使用 `as const` 但未用 `satisfies` 验证所有值均为字符串,无法在编译期捕获值类型错误
|
||||
- **规范依据**:编码规范 4.2.3「可用 `satisfies` 保持类型推导」
|
||||
- **改进建议**:`as const satisfies Record<string, string>`
|
||||
|
||||
#### BUG-P03:`AuthContext.roles` 类型过于宽松
|
||||
- **位置**:`src/shared/types/permissions.ts:111`
|
||||
- **问题**:`roles: string[]` 允许任意字符串,但项目角色是有限集合(admin/teacher/student/parent/grade_head/teaching_head)
|
||||
- **影响**:拼写错误(如 `"techer"`)无法在编译期发现;与 `proxy.ts:21` 中 `resolveDefaultPath(roles: string[])` 的硬编码角色判断形成隐患
|
||||
- **改进建议**:定义 `Role` 联合类型,`AuthContext.roles` 改为 `Role[]`,`ROLE_PERMISSIONS` 改为 `Record<Role, Permission[]>`
|
||||
|
||||
#### BUG-P04:`DataScope` 缺少 JSDoc 与字段说明
|
||||
- **位置**:`src/shared/types/permissions.ts:101-107`
|
||||
- **问题**:6 种数据范围类型未说明各自语义、适用角色、使用场景
|
||||
- **改进建议**:补充类型级 JSDoc,说明每种 `type` 的适用角色与语义
|
||||
|
||||
#### BUG-P05:`AuthContext` 接口缺少 JSDoc
|
||||
- **位置**:`src/shared/types/permissions.ts:109-114`
|
||||
- **问题**:接口无文档说明,使用者无法快速理解字段语义
|
||||
- **规范依据**:编码规范 5.4
|
||||
- **改进建议**:补充接口级 JSDoc,说明「认证上下文,由 `getAuthContext()` 返回,贯穿所有 Server Action」
|
||||
|
||||
#### BUG-P06:`DataScope.class_members` 缺少关联数据
|
||||
- **位置**:`src/shared/types/permissions.ts:104`
|
||||
- **问题**:`{ type: "class_members" }` 不携带 classIds,导致 data-access 层每次都需要额外查询学生所在班级,存在 N+1 查询风险
|
||||
- **影响**:`exams/data-access.ts`、`homework/data-access.ts` 等模块在过滤时需重复查询 `classMembers` 表
|
||||
- **改进建议**:在 `resolveDataScope` 中预查并携带 classIds:`{ type: "class_members"; classIds: string[] }`
|
||||
|
||||
---
|
||||
|
||||
### 2.3 action-state.test.ts — 严重度:中
|
||||
|
||||
#### BUG-T01:测试覆盖率不足
|
||||
- **位置**:`src/shared/types/action-state.test.ts:4-33`
|
||||
- **问题**:仅测试 3 种基本状态,缺少以下场景:
|
||||
1. `errors` 字段包含多个字段、每个字段多条错误消息
|
||||
2. `data` 为 `null`、`undefined`、`0`、`""` 等 falsy 值时的行为
|
||||
3. 同时存在 `errors` 和 `data`(虽然语义上不应出现,但类型允许)
|
||||
4. `message` 为空字符串
|
||||
- **规范依据**:编码规范十、测试规范「工具函数覆盖率目标 100%」
|
||||
|
||||
#### BUG-T02:测试描述缺少行为意图
|
||||
- **位置**:`src/shared/types/action-state.test.ts:4`
|
||||
- **问题**:`describe("ActionState")` 过于宽泛,未说明被测行为
|
||||
- **规范依据**:编码规范 10.2「描述应说明预期行为」
|
||||
- **改进建议**:`describe("ActionState 类型构造")`
|
||||
|
||||
---
|
||||
|
||||
### 2.4 跨文件违规(使用方导入问题)
|
||||
|
||||
#### BUG-X01:`exams/actions.ts` 类型导入违规
|
||||
- **位置**:`src/modules/exams/actions.ts:4`
|
||||
- **问题**:`import { ActionState } from "@/shared/types/action-state"` — `ActionState` 仅作为类型使用,应使用 `import type`
|
||||
- **规范依据**:编码规范 4.2.6「所有仅用于类型的导入必须使用 `import type`」
|
||||
- **改进建议**:`import type { ActionState } from "@/shared/types/action-state"`
|
||||
|
||||
#### BUG-X02:`questions/actions.ts` 类型导入违规
|
||||
- **位置**:`src/modules/questions/actions.ts:7`
|
||||
- **问题**:同 BUG-X01,`import { ActionState }` 应为 `import type { ActionState }`
|
||||
- **改进建议**:`import type { ActionState } from "@/shared/types/action-state"`
|
||||
|
||||
---
|
||||
|
||||
### 2.5 tsconfig.json 配置不达标(影响类型安全)
|
||||
|
||||
#### BUG-C01:`target` 低于规范要求
|
||||
- **位置**:`tsconfig.json:3`
|
||||
- **问题**:`"target": "ES2017"`,编码规范 4.1 要求 `"ES2022"`
|
||||
- **影响**:无法使用 ES2022 特性(如 `Array.at()`、`Object.hasOwn()`)
|
||||
- **改进建议**:`"target": "ES2022"`
|
||||
|
||||
#### BUG-C02:缺少 `noUncheckedIndexedAccess`
|
||||
- **位置**:`tsconfig.json`
|
||||
- **问题**:未启用 `noUncheckedIndexedAccess`,数组/对象索引访问返回 `T` 而非 `T | undefined`
|
||||
- **影响**:`permissions.ts:198` 中 `ROLE_PERMISSIONS[name]` 在 `name` 不存在时返回 `Permission[]` 而非 `Permission[] | undefined`,存在运行时风险
|
||||
- **规范依据**:编码规范 4.1
|
||||
- **改进建议**:`"noUncheckedIndexedAccess": true`
|
||||
|
||||
#### BUG-C03:缺少 `noImplicitReturns` 和 `noFallthroughCasesInSwitch`
|
||||
- **位置**:`tsconfig.json`
|
||||
- **问题**:未启用这两个严格检查
|
||||
- **规范依据**:编码规范 4.1
|
||||
- **改进建议**:补充 `"noImplicitReturns": true`、`"noFallthroughCasesInSwitch": true`、`"forceConsistentCasingInFileNames": true`
|
||||
|
||||
---
|
||||
|
||||
## 三、React 性能优化(应用 `vercel-react-best-practices` 技能)
|
||||
|
||||
### PERF-01:`use-permission.ts` 回调函数未 memoize
|
||||
- **位置**:`src/shared/hooks/use-permission.ts:11-25`
|
||||
- **问题**:`hasPermission`、`hasAnyPermission`、`hasAllPermissions`、`hasRole` 每次渲染都创建新函数引用,导致依赖这些函数的子组件不必要重渲染
|
||||
- **违反规则**:`rerender-functional-setstate`、`rerender-memo`
|
||||
- **改进建议**:使用 `useCallback` 包裹所有回调函数
|
||||
|
||||
### PERF-02:`permissions` 和 `roles` 数组未 memoize
|
||||
- **位置**:`src/shared/hooks/use-permission.ts:8-9`
|
||||
- **问题**:每次渲染都执行 `?? []` 创建新数组引用,导致下游 `useEffect`/`useMemo` 依赖项失效
|
||||
- **违反规则**:`rerender-derived-state`、`rerender-dependencies`
|
||||
- **改进建议**:使用 `useMemo` 包裹数组派生
|
||||
|
||||
### PERF-03:`as` 断言使用
|
||||
- **位置**:`src/shared/hooks/use-permission.ts:8-9`
|
||||
- **问题**:`as Permission[]` 和 `as string[]` 使用了类型断言,违反编码规范 4.2.3
|
||||
- **改进建议**:依赖 `next-auth.d.ts` 的类型增强(已存在),移除断言;或增加类型守卫
|
||||
|
||||
### `use-permission.ts` 完整改进示例
|
||||
|
||||
```typescript
|
||||
import { useCallback, useMemo } from "react"
|
||||
import { useSession } from "next-auth/react"
|
||||
import type { Permission } from "@/shared/types/permissions"
|
||||
|
||||
export function usePermission() {
|
||||
const { data: session } = useSession()
|
||||
|
||||
const permissions = useMemo(
|
||||
() => (session?.user?.permissions ?? []) as Permission[],
|
||||
[session?.user?.permissions]
|
||||
)
|
||||
const roles = useMemo(
|
||||
() => (session?.user?.roles ?? []) as string[],
|
||||
[session?.user?.roles]
|
||||
)
|
||||
|
||||
const hasPermission = useCallback(
|
||||
(permission: Permission): boolean => permissions.includes(permission),
|
||||
[permissions]
|
||||
)
|
||||
const hasAnyPermission = useCallback(
|
||||
(...perms: Permission[]): boolean => perms.some((p) => permissions.includes(p)),
|
||||
[permissions]
|
||||
)
|
||||
const hasAllPermissions = useCallback(
|
||||
(...perms: Permission[]): boolean => perms.every((p) => permissions.includes(p)),
|
||||
[permissions]
|
||||
)
|
||||
const hasRole = useCallback(
|
||||
(role: string): boolean => roles.includes(role),
|
||||
[roles]
|
||||
)
|
||||
|
||||
return { permissions, roles, hasPermission, hasAnyPermission, hasAllPermissions, hasRole }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、Web 界面规范审查(应用 `web-design-guidelines` 技能)
|
||||
|
||||
> 说明:`src/shared/types/` 为纯类型定义文件,无直接 UI 代码。以下审查针对类型定义所支撑的 UI 实现层(`use-permission.ts`、`proxy.ts`、`auth-guard.ts`)是否符合 Web Interface Guidelines。
|
||||
|
||||
### UI-01:权限状态可能导致 hydration mismatch
|
||||
- **位置**:`src/shared/hooks/use-permission.ts:7`
|
||||
- **问题**:`useSession()` 在服务端渲染时返回 `null`/`loading`,客户端首次渲染后才有权限数据,导致权限相关的 UI(如菜单项、按钮)在 hydration 后闪烁
|
||||
- **违反规则**:Web Interface Guidelines — Hydration Safety
|
||||
- **改进建议**:
|
||||
1. 服务端组件应通过 `auth()` 获取权限并作为 props 传递
|
||||
2. 客户端组件在 `session === null` 时渲染骨架屏或占位,避免权限相关 UI 闪烁
|
||||
3. 对权限相关的动态 UI 使用 `suppressHydrationWarning` 或延迟渲染
|
||||
|
||||
### UI-02:权限不足时的重定向未反映在 URL
|
||||
- **位置**:`src/proxy.ts:75-76`
|
||||
- **问题**:权限不足时直接重定向到默认页,URL 中未携带原始路径信息,用户无法知道「为何被重定向」
|
||||
- **违反规则**:Web Interface Guidelines — Navigation & State「URL reflects state」
|
||||
- **改进建议**:重定向时携带 `?from=originalPath&reason=forbidden` 参数,目标页显示提示
|
||||
|
||||
### UI-03:错误消息缺少修复步骤
|
||||
- **位置**:`src/shared/lib/auth-guard.ts:13`
|
||||
- **问题**:`Permission denied: ${permission}` 仅描述问题,未提供下一步操作
|
||||
- **违反规则**:Web Interface Guidelines — Content & Copy「Error messages include fix/next step」
|
||||
- **改进建议**:`权限不足:需要 ${permission} 权限。请联系管理员授权或切换账号。`
|
||||
|
||||
---
|
||||
|
||||
## 五、架构文档同步问题
|
||||
|
||||
### DOC-01:004 文件行数记录过期
|
||||
- **位置**:`docs/architecture/004_architecture_impact_map.md:408`
|
||||
- **问题**:记录 `types/permissions.ts | 92 | 54 个权限点常量`,实际文件 114 行,含 `DataScope`、`AuthContext` 类型定义
|
||||
- **改进建议**:更新为 `114 行 | 54 个权限点 + DataScope + AuthContext`
|
||||
|
||||
### DOC-02:005 JSON 中 `DataScope` 定义字段顺序与代码不一致
|
||||
- **位置**:`docs/architecture/005_architecture_data.json:993`
|
||||
- **问题**:JSON 中字段顺序为 `all, owned, class_taught, grade_managed, class_members, children`,代码中为 `all, owned, class_members, grade_managed, class_taught, children`
|
||||
- **改进建议**:同步 JSON 字段顺序与源码一致
|
||||
|
||||
### DOC-03:缺少 `Role` 类型定义记录
|
||||
- **问题**:若按 BUG-P03 建议新增 `Role` 类型,需在 005 JSON 的 `shared.types` 数组中补充记录
|
||||
- **改进建议**:新增 `Role` 类型节点,记录 `usedBy: ["auth-guard", "permissions", "proxy"]`
|
||||
|
||||
---
|
||||
|
||||
## 六、问题汇总统计
|
||||
|
||||
| 严重度 | 数量 | 问题编号 |
|
||||
|--------|------|----------|
|
||||
| 高 | 8 | BUG-A01, BUG-A02, BUG-P01, BUG-P02, BUG-P03, BUG-P04, BUG-P05, BUG-P06 |
|
||||
| 中 | 6 | BUG-T01, BUG-T02, BUG-X01, BUG-X02, BUG-C01, BUG-C02 |
|
||||
| 低 | 3 | BUG-C03, DOC-01, DOC-02, DOC-03 |
|
||||
| 性能 | 3 | PERF-01, PERF-02, PERF-03 |
|
||||
| 界面 | 3 | UI-01, UI-02, UI-03 |
|
||||
| **合计** | **23** | |
|
||||
|
||||
---
|
||||
|
||||
## 七、修复优先级建议
|
||||
|
||||
### P0(立即修复 — 影响类型安全与一致性)
|
||||
1. BUG-A01:移除 `action-state.ts` 分号
|
||||
2. BUG-X01、BUG-X02:修正 `import type` 违规
|
||||
3. BUG-C01、BUG-C02、BUG-C03:升级 `tsconfig.json`
|
||||
|
||||
### P1(本迭代修复 — 影响可维护性)
|
||||
4. BUG-P01:统一权限点命名
|
||||
5. BUG-P02:`Permissions` 添加 `satisfies`
|
||||
6. BUG-P03:新增 `Role` 类型
|
||||
7. BUG-A02、BUG-P04、BUG-P05:补充 JSDoc
|
||||
8. PERF-01、PERF-02、PERF-03:`use-permission.ts` 性能优化
|
||||
|
||||
### P2(下迭代修复 — 增强健壮性)
|
||||
9. BUG-T01、BUG-T02:补充测试用例
|
||||
10. BUG-P06:`DataScope.class_members` 携带 classIds
|
||||
11. UI-01、UI-02、UI-03:界面规范改进
|
||||
|
||||
### P3(文档同步)
|
||||
12. DOC-01、DOC-02、DOC-03:同步架构文档
|
||||
|
||||
---
|
||||
|
||||
## 八、验证命令
|
||||
|
||||
修复完成后应运行以下命令确保零错误:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npx tsc --noEmit
|
||||
npm run test:unit -- action-state
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> 报告生成人:AI Agent(GLM-5.2)
|
||||
> 核查方法:人工逐行审查 + 架构图比对 + 技能规则匹配
|
||||
711
bugs/student_bug.md
Normal file
711
bugs/student_bug.md
Normal file
@@ -0,0 +1,711 @@
|
||||
# `src/app/(dashboard)/student` 前端规范核查报告
|
||||
|
||||
> 核查日期:2026-06-18
|
||||
> 核查范围:`src/app/(dashboard)/student/` 目录下所有前端文件(含 `loading.tsx`)+ 关联模块组件 `src/modules/student/components/*`
|
||||
> 依据文档:项目规则、编码规范 `docs/standards/coding-standards.md`、架构影响地图 004、架构数据 005
|
||||
> 应用技能:`vercel-react-best-practices`、`web-artifacts-builder`、`web-design-guidelines`
|
||||
|
||||
---
|
||||
|
||||
## 一、核查文件清单
|
||||
|
||||
### 1.1 路由页面文件(11 个)
|
||||
|
||||
| 文件 | 行数 | 类型 | 用途 |
|
||||
|------|------|------|------|
|
||||
| [dashboard/page.tsx](../src/app/(dashboard)/student/dashboard/page.tsx) | 88 | Server Component | 学生仪表盘 |
|
||||
| [dashboard/loading.tsx](../src/app/(dashboard)/student/dashboard/loading.tsx) | 60 | Loading UI | 仪表盘骨架屏 |
|
||||
| [attendance/page.tsx](../src/app/(dashboard)/student/attendance/page.tsx) | 40 | Server Component | 学生考勤 |
|
||||
| [diagnostic/page.tsx](../src/app/(dashboard)/student/diagnostic/page.tsx) | 31 | Server Component | 学情诊断 |
|
||||
| [elective/page.tsx](../src/app/(dashboard)/student/elective/page.tsx) | 49 | Server Component | 选课中心 |
|
||||
| [grades/page.tsx](../src/app/(dashboard)/student/grades/page.tsx) | 40 | Server Component | 我的成绩 |
|
||||
| [learning/assignments/page.tsx](../src/app/(dashboard)/student/learning/assignments/page.tsx) | 167 | Server Component | 作业列表 |
|
||||
| [learning/assignments/[assignmentId]/page.tsx](../src/app/(dashboard)/student/learning/assignments/[assignmentId]/page.tsx) | 53 | Server Component | 作业作答/复习 |
|
||||
| [learning/courses/page.tsx](../src/app/(dashboard)/student/learning/courses/page.tsx) | 39 | Server Component | 课程列表 |
|
||||
| [learning/courses/loading.tsx](../src/app/(dashboard)/student/learning/courses/loading.tsx) | 28 | Loading UI | 课程骨架屏 |
|
||||
| [learning/textbooks/page.tsx](../src/app/(dashboard)/student/learning/textbooks/page.tsx) | 71 | Server Component | 教材列表 |
|
||||
| [learning/textbooks/[id]/page.tsx](../src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx) | 76 | Server Component | 教材阅读 |
|
||||
| [schedule/page.tsx](../src/app/(dashboard)/student/schedule/page.tsx) | 54 | Server Component | 课表 |
|
||||
| [schedule/loading.tsx](../src/app/(dashboard)/student/schedule/loading.tsx) | 31 | Loading UI | 课表骨架屏 |
|
||||
|
||||
### 1.2 关联模块组件(3 个)
|
||||
|
||||
| 文件 | 行数 | 类型 | 用途 |
|
||||
|------|------|------|------|
|
||||
| [student-courses-view.tsx](../src/modules/student/components/student-courses-view.tsx) | 156 | Client Component | 课程视图 + 加入班级表单 |
|
||||
| [student-schedule-filters.tsx](../src/modules/student/components/student-schedule-filters.tsx) | 32 | Client Component | 课表筛选器 |
|
||||
| [student-schedule-view.tsx](../src/modules/student/components/student-schedule-view.tsx) | 88 | Server Component | 课表视图 |
|
||||
|
||||
### 1.3 缺失文件
|
||||
|
||||
| 缺失类型 | 影响范围 | 说明 |
|
||||
|---------|---------|------|
|
||||
| `layout.tsx` | 整个 student 路由组 | 无统一布局,每个页面重复写 `<div className="... p-8">` 容器 |
|
||||
| `error.tsx` | 整个 student 路由组 | 无错误边界,Server Component 抛错时显示 Next.js 默认错误页 |
|
||||
| `loading.tsx` | attendance / diagnostic / elective / grades / learning/assignments / learning/assignments/[assignmentId] / learning/textbooks / learning/textbooks/[id] | 8 个路由无骨架屏,跳转时白屏 |
|
||||
| `not-found.tsx` | 整个 student 路由组 | `notFound()` 调用时显示 Next.js 默认 404 |
|
||||
|
||||
---
|
||||
|
||||
## 二、违规问题清单
|
||||
|
||||
### 2.1 认证模式严重不一致 — 严重度:高
|
||||
|
||||
#### BUG-A01:三种认证模式混用
|
||||
- **位置**:整个 student 目录
|
||||
- **问题**:同一角色(student)的页面使用了 3 种不同的认证方式:
|
||||
|
||||
| 模式 | 使用页面 | 问题 |
|
||||
|------|---------|------|
|
||||
| `getAuthContext()` from `@/shared/lib/auth-guard` | attendance / diagnostic / grades | ✅ 推荐 |
|
||||
| `auth()` from `@/auth` | elective | ⚠️ 直接调用 auth(),绕过 auth-guard 抽象 |
|
||||
| `getDemoStudentUser()` from `@/modules/homework/data-access` | dashboard / learning/* / schedule | ⚠️ 依赖 homework 模块获取用户身份 |
|
||||
|
||||
- **规范依据**:编码规范 8.3「统一通过 `getAuthContext()` / `requirePermission()` 获取会话」
|
||||
- **影响**:
|
||||
1. `elective/page.tsx` 直接 `import { auth } from "@/auth"`,违反分层规则(app 层不应直接依赖根模块 `@/auth`,应通过 `shared/lib/auth-guard`)
|
||||
2. `getDemoStudentUser()` 名为 "Demo" 实为真实查询,命名误导
|
||||
3. `getDemoStudentUser()` 定义在 `homework/data-access.ts`,导致 6 个 student 页面为获取用户身份而依赖 homework 模块,违反模块封装
|
||||
- **改进建议**:
|
||||
1. 将 `getDemoStudentUser` 迁移到 `users/data-access.ts` 并重命名为 `getCurrentStudentUser()` 或 `getStudentSession()`
|
||||
2. 所有 student 页面统一使用 `getAuthContext()` 获取 `ctx.userId`,再按需查询学生信息
|
||||
3. 移除 `elective/page.tsx` 中的 `import { auth } from "@/auth"`
|
||||
|
||||
#### BUG-A02:`getDemoStudentUser` 命名误导
|
||||
- **位置**:`src/modules/homework/data-access.ts:475`
|
||||
- **问题**:函数名包含 "Demo",但实际执行真实 DB 查询(JOIN users + usersToRoles + roles WHERE roles.name = "student"),并非演示用桩函数
|
||||
- **影响**:开发者看到 "Demo" 会误以为是临时实现,不敢用于生产;或误以为返回固定演示数据
|
||||
- **改进建议**:重命名为 `getCurrentStudentUser()` 或 `resolveCurrentStudent()`
|
||||
|
||||
#### BUG-A03:`getDemoStudentUser` 放置在错误的模块
|
||||
- **位置**:`src/modules/homework/data-access.ts:475-490`
|
||||
- **问题**:学生身份查询属于 `users` 模块职责,却放在 `homework` 模块
|
||||
- **规范依据**:项目规则「模块标准结构」+ 编码规范 2.2「模块封装」
|
||||
- **影响**:`dashboard`、`learning/assignments`、`learning/courses`、`learning/textbooks`、`learning/textbooks/[id]`、`schedule` 6 个页面为获取用户身份而 `import` homework 模块,造成虚假依赖
|
||||
- **改进建议**:迁移到 `src/modules/users/data-access.ts`,导出为 `getCurrentStudentUser()`
|
||||
|
||||
---
|
||||
|
||||
### 2.2 `learning/assignments/page.tsx` — 严重度:高
|
||||
|
||||
#### BUG-L01:中英文混排
|
||||
- **位置**:`src/app/(dashboard)/student/learning/assignments/page.tsx:81, 122`
|
||||
- **问题**:在英文 UI 中插入中文标签
|
||||
```tsx
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">未答题</div>
|
||||
...
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">已答题</div>
|
||||
```
|
||||
- **规范依据**:项目为 K12 中文教务系统,但本页面其余文案均为英文("Due"、"Attempts"、"Score"、"Start"、"Continue"),中英文混排显得不专业
|
||||
- **改进建议**:统一为英文 "Pending" / "Completed",或整页改为中文
|
||||
|
||||
#### BUG-L02:卡片渲染逻辑重复
|
||||
- **位置**:`src/app/(dashboard)/student/learning/assignments/page.tsx:83-116` 与 `124-157`
|
||||
- **问题**:未答题区和已答题区的卡片 JSX 完全相同(34 行 × 2 = 68 行重复),仅数据源不同
|
||||
- **规范依据**:编码规范「DRY 原则」+ 项目规则「单文件行数建议 ≤ 500 行」
|
||||
- **改进建议**:抽取为 `<AssignmentCard assignment={a} />` 组件,循环调用
|
||||
|
||||
#### BUG-L03:JSX 语法格式错误
|
||||
- **位置**:`src/app/(dashboard)/student/learning/assignments/page.tsx:162`
|
||||
- **问题**:行尾 `})}` 多了一个 `)`
|
||||
```tsx
|
||||
)})}
|
||||
```
|
||||
应为:
|
||||
```tsx
|
||||
))}
|
||||
```
|
||||
- **影响**:虽然能编译通过(因外层有 `(`),但格式混乱,IDE 自动格式化后会变化,造成 git diff 噪音
|
||||
- **改进建议**:修正为 `))}`
|
||||
|
||||
#### BUG-L04:`getStatusVariant` 对 submitted 和 in_progress 返回相同值
|
||||
- **位置**:`src/app/(dashboard)/student/learning/assignments/page.tsx:13-18`
|
||||
- **问题**:
|
||||
```tsx
|
||||
if (status === "submitted") return "secondary"
|
||||
if (status === "in_progress") return "secondary"
|
||||
```
|
||||
两种状态视觉上无法区分,学生无法快速辨别「已提交待批改」和「进行中」
|
||||
- **改进建议**:`in_progress` 改为 `"outline"` 或新增 `"in_progress"` 专属样式
|
||||
|
||||
#### BUG-L05:状态判断函数参数类型过宽
|
||||
- **位置**:`src/app/(dashboard)/student/learning/assignments/page.tsx:13, 20, 27, 34, 39`
|
||||
- **问题**:`getStatusVariant(status: string)` 等函数参数为 `string`,但 `a.progressStatus` 实际类型是 `StudentHomeworkProgressStatus` 联合类型
|
||||
- **规范依据**:编码规范 4.2「优先使用精确类型」
|
||||
- **改进建议**:参数类型改为 `StudentHomeworkProgressStatus`,并在 switch 中穷举
|
||||
|
||||
#### BUG-L06:`new Map<string, typeof assignments>()` 类型不优雅
|
||||
- **位置**:`src/app/(dashboard)/student/learning/assignments/page.tsx:63`
|
||||
- **问题**:使用 `typeof assignments` 作为 Map 值类型,将类型绑定到变量,可读性差
|
||||
- **改进建议**:导入显式类型 `new Map<string, StudentHomeworkAssignment[]>()`
|
||||
|
||||
---
|
||||
|
||||
### 2.3 `learning/textbooks/page.tsx` — 严重度:中
|
||||
|
||||
#### BUG-T01:存在注释掉的代码
|
||||
- **位置**:`src/app/(dashboard)/student/learning/textbooks/page.tsx:42-50`
|
||||
- **问题**:
|
||||
```tsx
|
||||
{/* <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Textbooks</h2>
|
||||
<p className="text-muted-foreground">Browse your course textbooks.</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/student/dashboard">Back</Link>
|
||||
</Button>
|
||||
</div> */}
|
||||
```
|
||||
- **规范依据**:编码规范禁止提交注释掉的代码(应删除或用版本管理)
|
||||
- **改进建议**:删除注释块
|
||||
|
||||
#### BUG-T02:缺少页面标题
|
||||
- **位置**:`src/app/(dashboard)/student/learning/textbooks/page.tsx`
|
||||
- **问题**:其他 student 页面都有 `<h2 className="text-2xl font-bold tracking-tight">` 标题,本页面因注释掉了标题块,直接渲染 `TextbookFilters`,与其他页面不一致
|
||||
- **改进建议**:恢复标题(不带"Back"按钮),保持视觉一致性
|
||||
|
||||
#### BUG-T03:`student` 变量未真正使用
|
||||
- **位置**:`src/app/(dashboard)/student/learning/textbooks/page.tsx:23-31`
|
||||
- **问题**:`student` 仅用于 "No user" 检查,`getTextbooks(q, subject, grade)` 不依赖学生身份
|
||||
- **影响**:任何登录学生都能浏览所有教材,无年级/科目过滤;如设计如此,则 `student` 检查可简化为 `getAuthContext()` 仅做认证
|
||||
- **改进建议**:若教材对所有学生开放,改用 `getAuthContext()` 仅做认证;若应按年级过滤,则 `getTextbooks` 增加 `grade` 参数
|
||||
|
||||
---
|
||||
|
||||
### 2.4 `learning/textbooks/[id]/page.tsx` — 严重度:中
|
||||
|
||||
#### BUG-TD01:`student` 变量未使用
|
||||
- **位置**:`src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx:18-31`
|
||||
- **问题**:同 BUG-T03,`student` 仅用于 "No user" 检查,教材内容查询不依赖学生身份
|
||||
- **改进建议**:同 BUG-T03
|
||||
|
||||
#### BUG-TD02:错误处理不一致
|
||||
- **位置**:`src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx:19, 41`
|
||||
- **问题**:
|
||||
- `if (!student)` 返回完整 `EmptyState` 页面
|
||||
- `if (!textbook) notFound()` 抛出 404
|
||||
同一文件内两种错误处理模式
|
||||
- **改进建议**:统一为 `notFound()` 或都返回 `EmptyState`
|
||||
|
||||
#### BUG-TD03:装饰性 `<span>` 缺少 `aria-hidden`
|
||||
- **位置**:`src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx:49`
|
||||
- **问题**:
|
||||
```tsx
|
||||
<span className="hidden sm:inline-block w-px h-4 bg-border" />
|
||||
```
|
||||
纯装饰性分隔线,屏幕阅读器会朗读空内容
|
||||
- **规范依据**:Web Interface Guidelines — Accessibility「装饰性元素应 `aria-hidden`」
|
||||
- **改进建议**:添加 `aria-hidden="true"`
|
||||
|
||||
---
|
||||
|
||||
### 2.5 `dashboard/page.tsx` — 严重度:中
|
||||
|
||||
#### BUG-D01:`as` 类型断言违规
|
||||
- **位置**:`src/app/(dashboard)/student/dashboard/page.tsx:11`
|
||||
- **问题**:
|
||||
```tsx
|
||||
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
```
|
||||
- **规范依据**:编码规范 4.2.3「禁止 `as` 断言(除非从 `unknown` 转换)」
|
||||
- **改进建议**:使用类型守卫或重写为:
|
||||
```tsx
|
||||
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
||||
const day = d.getDay()
|
||||
const weekday = day === 0 ? 7 : day
|
||||
if (weekday < 1 || weekday > 7) throw new Error(`Invalid weekday: ${weekday}`)
|
||||
return weekday
|
||||
}
|
||||
```
|
||||
|
||||
#### BUG-D02:缺少页面标题容器
|
||||
- **位置**:`src/app/(dashboard)/student/dashboard/page.tsx:76-87`
|
||||
- **问题**:其他 student 页面都有 `<div className="... p-8"><div><h2>...</h2><p>...</p></div>...</div>` 容器,本页面直接返回 `<StudentDashboard />`,无外层 padding 和标题
|
||||
- **影响**:视觉上与其他页面不一致(无 padding,无标题)
|
||||
- **改进建议**:添加统一的页面容器和标题,或将容器抽到 `layout.tsx`
|
||||
|
||||
#### BUG-D03:`EmptyState` 的 `icon` 使用 `Inbox` 不合适
|
||||
- **位置**:`src/app/(dashboard)/student/dashboard/page.tsx:5, 22`
|
||||
- **问题**:用户未找到时显示 `Inbox` 图标,语义不匹配
|
||||
- **改进建议**:使用 `UserX` 或 `UserMinus` 图标
|
||||
|
||||
---
|
||||
|
||||
### 2.6 `schedule/page.tsx` — 严重度:低
|
||||
|
||||
#### BUG-S01:嵌套三元表达式可读性差
|
||||
- **位置**:`src/app/(dashboard)/student/schedule/page.tsx:38`
|
||||
- **问题**:
|
||||
```tsx
|
||||
const classId = typeof classIdParam === "string" ? classIdParam : Array.isArray(classIdParam) ? classIdParam[0] : "all"
|
||||
```
|
||||
- **规范依据**:编码规范「避免嵌套三元」
|
||||
- **改进建议**:抽为独立函数或使用 if 语句
|
||||
|
||||
#### BUG-S02:`searchParams` 类型未共享
|
||||
- **位置**:`src/app/(dashboard)/student/schedule/page.tsx:11` 和 `learning/textbooks/page.tsx:11`
|
||||
- **问题**:两处定义相同的 `type SearchParams = { [key: string]: string | string[] | undefined }`
|
||||
- **改进建议**:抽取到 `shared/types` 或 `shared/lib/utils`
|
||||
|
||||
---
|
||||
|
||||
### 2.7 `elective/page.tsx` — 严重度:中
|
||||
|
||||
#### BUG-E01:直接调用 `auth()` 绕过 auth-guard
|
||||
- **位置**:`src/app/(dashboard)/student/elective/page.tsx:1, 11`
|
||||
- **问题**:
|
||||
```tsx
|
||||
import { auth } from "@/auth"
|
||||
...
|
||||
const session = await auth()
|
||||
const studentId = String(session?.user?.id ?? "")
|
||||
```
|
||||
- **规范依据**:项目规则「app 层不应直接依赖 `@/auth`」+ 编码规范 8.3
|
||||
- **改进建议**:改用 `getAuthContext()`:
|
||||
```tsx
|
||||
const ctx = await getAuthContext()
|
||||
const studentId = ctx.userId
|
||||
```
|
||||
|
||||
#### BUG-E02:`String(... ?? "")` 模式冗余
|
||||
- **位置**:`src/app/(dashboard)/student/elective/page.tsx:12`
|
||||
- **问题**:`String(session?.user?.id ?? "")` — `session.user.id` 已是 string 类型,`String()` 包裹多余
|
||||
- **改进建议**:使用 `getAuthContext()` 后直接用 `ctx.userId`
|
||||
|
||||
---
|
||||
|
||||
### 2.8 `student-courses-view.tsx` — 严重度:中
|
||||
|
||||
#### BUG-C01:catch 块吞掉错误
|
||||
- **位置**:`src/modules/student/components/student-courses-view.tsx:39-41`
|
||||
- **问题**:
|
||||
```tsx
|
||||
} catch {
|
||||
toast.error("Failed to join class")
|
||||
}
|
||||
```
|
||||
错误被吞掉,无 `console.error` 记录,调试困难
|
||||
- **规范依据**:编码规范 9.2「错误必须可观测」
|
||||
- **改进建议**:
|
||||
```tsx
|
||||
} catch (err) {
|
||||
console.error("[joinClass] failed:", err)
|
||||
toast.error("Failed to join class")
|
||||
}
|
||||
```
|
||||
|
||||
#### BUG-C02:未使用 `useFormStatus` / `useTransition`
|
||||
- **位置**:`src/modules/student/components/student-courses-view.tsx:26-44`
|
||||
- **问题**:使用本地 `useState` + `setIsWorking` 管理 loading 状态,但 Next.js 16 推荐 `useFormStatus`(用于 `<form action>`)或 `useTransition`
|
||||
- **违反规则**:`rerender-transitions`、`rendering-usetransition-loading`
|
||||
- **改进建议**:使用 `useTransition` 包裹 Server Action 调用
|
||||
|
||||
#### BUG-C03:表单缺少客户端格式校验
|
||||
- **位置**:`src/modules/student/components/student-courses-view.tsx:136-148`
|
||||
- **问题**:`maxLength={6}` 和 `inputMode="numeric"` 不阻止字母输入,用户可提交 "abcdef"
|
||||
- **改进建议**:添加 `pattern="\d{6}"` 或 `onChange` 过滤非数字字符
|
||||
|
||||
---
|
||||
|
||||
### 2.9 `student-schedule-view.tsx` — 严重度:低
|
||||
|
||||
#### BUG-SV01:`for...of` 修改 Map 后再次 set
|
||||
- **位置**:`src/modules/student/components/student-schedule-view.tsx:36-39`
|
||||
- **问题**:
|
||||
```tsx
|
||||
for (const [day, list] of itemsByDay) {
|
||||
list.sort((a, b) => a.startTime.localeCompare(b.startTime))
|
||||
itemsByDay.set(day, list) // 多余,sort 已 in-place
|
||||
}
|
||||
```
|
||||
`list.sort()` 已原地排序,`itemsByDay.set(day, list)` 多余
|
||||
- **改进建议**:删除 `itemsByDay.set(day, list)` 行
|
||||
|
||||
---
|
||||
|
||||
### 2.10 跨文件一致性 — 严重度:中
|
||||
|
||||
#### BUG-X01:页面容器 className 不统一
|
||||
- **位置**:多个页面
|
||||
- **问题**:存在 4 种不同的容器写法:
|
||||
|
||||
| className | 使用页面 |
|
||||
|-----------|---------|
|
||||
| `h-full flex-1 flex-col space-y-8 p-8 md:flex` | attendance / diagnostic / grades / learning/textbooks / learning/textbooks/[id] |
|
||||
| `flex h-full flex-col space-y-8 p-8` | elective / learning/courses / schedule |
|
||||
| `flex h-full flex-col space-y-4 p-6` | learning/assignments/[assignmentId] |
|
||||
| `h-full flex-1 flex-col space-y-8 p-8 md:flex` | learning/assignments |
|
||||
| 无容器 | dashboard |
|
||||
|
||||
- **改进建议**:抽取到 `student/layout.tsx` 的 `<main>` 中,统一 padding 和布局
|
||||
|
||||
#### BUG-X02:"No user" 处理方式不一致
|
||||
- **位置**:多个页面
|
||||
- **问题**:
|
||||
- `dashboard`、`learning/courses`、`learning/textbooks`、`learning/textbooks/[id]`、`schedule`、`learning/assignments`、`elective`:返回 `EmptyState`
|
||||
- `learning/assignments/[assignmentId]`:调用 `notFound()`
|
||||
- `attendance`、`grades`:返回 `EmptyState`(但检查的是 `summary` 而非 `student`)
|
||||
- **改进建议**:未认证场景应由 `proxy.ts` 拦截,页面内统一用 `notFound()` 或统一 `EmptyState`
|
||||
|
||||
#### BUG-X03:图标选择不一致
|
||||
- **位置**:多个页面
|
||||
- **问题**:同样 "No user found" 场景使用不同图标:
|
||||
- `dashboard`、`learning/courses`、`learning/textbooks`、`schedule`、`learning/assignments`:`Inbox`
|
||||
- `attendance`:`CalendarCheck`
|
||||
- `grades`:`GraduationCap`
|
||||
- `elective`:`Inbox`
|
||||
- **改进建议**:未认证场景统一使用 `UserX`;空数据场景使用业务相关图标
|
||||
|
||||
---
|
||||
|
||||
## 三、React 性能优化(应用 `vercel-react-best-practices` 技能)
|
||||
|
||||
### PERF-01:`student-courses-view.tsx` 未使用 `useTransition`
|
||||
- **位置**:`src/modules/student/components/student-courses-view.tsx:28-44`
|
||||
- **问题**:Server Action `joinClassByInvitationCodeAction` 用 `useState` 管理 loading,阻塞 UI 线程
|
||||
- **违反规则**:`rerender-transitions`、`rendering-usetransition-loading`
|
||||
- **改进建议**:
|
||||
```tsx
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const handleJoin = async (formData: FormData) => {
|
||||
startTransition(async () => {
|
||||
const res = await joinClassByInvitationCodeAction(null, formData)
|
||||
...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### PERF-02:`student-courses-view.tsx` 卡片列表未 memoize
|
||||
- **位置**:`src/modules/student/components/student-courses-view.tsx:50-108`
|
||||
- **问题**:`classes.map(...)` 每次渲染都重新创建 6+ 个 Card 元素,当 `code` 输入变化时所有卡片重渲染
|
||||
- **违反规则**:`rerender-memo`、`rerender-no-inline-components`
|
||||
- **改进建议**:抽取 `<ClassCard class={c} />` 组件并用 `React.memo` 包裹
|
||||
|
||||
### PERF-03:`student-schedule-filters.tsx` 的 `options` 依赖 `classes` 引用
|
||||
- **位置**:`src/modules/student/components/student-schedule-filters.tsx:12`
|
||||
- **问题**:`useMemo(() => [...classes], [classes])` — 父组件每次传入新 `classes` 数组引用时都重新计算
|
||||
- **违反规则**:`rerender-dependencies`
|
||||
- **现状**:已用 `useMemo`,但依赖项是父组件传入的 prop,若父组件不 memoize 则失效
|
||||
- **改进建议**:可接受,但建议父组件 `schedule/page.tsx` 用 `React.cache` 或在 Server Component 层面稳定引用
|
||||
|
||||
### PERF-04:`dashboard/page.tsx` 多次 `filter` 遍历同一数组
|
||||
- **位置**:`src/app/(dashboard)/student/dashboard/page.tsx:40-52`
|
||||
- **问题**:
|
||||
```tsx
|
||||
const dueSoonCount = assignments.filter(...).length
|
||||
const overdueCount = assignments.filter(...).length
|
||||
const gradedCount = assignments.filter(...).length
|
||||
```
|
||||
3 次遍历同一数组,O(3n)
|
||||
- **违反规则**:`js-combine-iterations`
|
||||
- **改进建议**:单次遍历统计:
|
||||
```tsx
|
||||
let dueSoonCount = 0, overdueCount = 0, gradedCount = 0
|
||||
for (const a of assignments) {
|
||||
if (a.progressStatus === "graded") { gradedCount++; continue }
|
||||
if (!a.dueAt) continue
|
||||
const due = new Date(a.dueAt)
|
||||
if (due >= now && due <= in7Days) dueSoonCount++
|
||||
else if (due < now) overdueCount++
|
||||
}
|
||||
```
|
||||
|
||||
### PERF-05:`learning/assignments/page.tsx` 重复 `filter` 调用
|
||||
- **位置**:`src/app/(dashboard)/student/learning/assignments/page.tsx:73-74`
|
||||
- **问题**:
|
||||
```tsx
|
||||
const answeredItems = items.filter((a) => isAnswered(a.progressStatus))
|
||||
const unansweredItems = items.filter((a) => !isAnswered(a.progressStatus))
|
||||
```
|
||||
2 次遍历同一数组
|
||||
- **违反规则**:`js-combine-iterations`
|
||||
- **改进建议**:单次遍历分桶:
|
||||
```tsx
|
||||
const { answered, unanswered } = items.reduce(
|
||||
(acc, a) => {
|
||||
isAnswered(a.progressStatus) ? acc.answered.push(a) : acc.unanswered.push(a)
|
||||
return acc
|
||||
},
|
||||
{ answered: [], unanswered: [] }
|
||||
)
|
||||
```
|
||||
|
||||
### PERF-06:`student-schedule-view.tsx` 在渲染期构建 Map
|
||||
- **位置**:`src/modules/student/components/student-schedule-view.tsx:30-39`
|
||||
- **问题**:每次渲染都重建 `itemsByDay` Map 并排序
|
||||
- **现状**:作为 Server Component 每次请求只渲染一次,影响较小
|
||||
- **违反规则**:`js-cache-function-results`(轻度)
|
||||
- **改进建议**:可接受,若未来改为 Client Component 则需 `useMemo`
|
||||
|
||||
### PERF-07:`dashboard/page.tsx` 已正确使用 `Promise.all` 并行获取
|
||||
- **位置**:`src/app/(dashboard)/student/dashboard/page.tsx:29-34`
|
||||
- **现状**:✅ 符合 `async-parallel` 规则
|
||||
- **说明**:4 个独立查询并行执行,无需优化
|
||||
|
||||
### PERF-08:`diagnostic/page.tsx` 已正确使用 `Promise.all`
|
||||
- **位置**:`src/app/(dashboard)/student/diagnostic/page.tsx:12-15`
|
||||
- **现状**:✅ 符合 `async-parallel` 规则
|
||||
|
||||
### PERF-09:`schedule/page.tsx` 已正确使用 `Promise.all`
|
||||
- **位置**:`src/app/(dashboard)/student/schedule/page.tsx:31-35`
|
||||
- **现状**:✅ 符合 `async-parallel` 规则
|
||||
|
||||
---
|
||||
|
||||
## 四、Web 界面规范审查(应用 `web-design-guidelines` 技能)
|
||||
|
||||
### UI-01:缺少 `loading.tsx` 导致白屏
|
||||
- **位置**:attendance / diagnostic / elective / grades / learning/assignments / learning/assignments/[assignmentId] / learning/textbooks / learning/textbooks/[id]
|
||||
- **问题**:8 个路由无骨架屏,跳转时显示白屏,违反 "Perceived Performance" 原则
|
||||
- **违反规则**:Web Interface Guidelines — Perceived Performance「Always show loading state」
|
||||
- **改进建议**:为每个缺失的路由添加 `loading.tsx`,参考已有的 `dashboard/loading.tsx` 模式
|
||||
|
||||
### UI-02:缺少 `error.tsx` 错误边界
|
||||
- **位置**:整个 student 路由组
|
||||
- **问题**:Server Component 抛错时显示 Next.js 默认错误页,无法"恢复"
|
||||
- **违反规则**:Web Interface Guidelines — Error Handling「Provide recoverable error states」
|
||||
- **改进建议**:在 `student/error.tsx` 中提供"重试"按钮:
|
||||
```tsx
|
||||
"use client"
|
||||
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Something went wrong"
|
||||
description={error.message}
|
||||
action={{ label: "Try again", onClick: reset }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### UI-03:装饰性元素缺少 `aria-hidden`
|
||||
- **位置**:
|
||||
- `learning/textbooks/[id]/page.tsx:49` — 分隔线 `<span>`
|
||||
- `learning/assignments/page.tsx:98, 139` — `<span className="px-2">•</span>` 分隔点
|
||||
- `learning/assignments/[assignmentId]/page.tsx:45` — `<span className="mx-2">•</span>`
|
||||
- `student-courses-view.tsx:63` — `<span>•</span>`
|
||||
- **问题**:屏幕阅读器会朗读"•"或空内容
|
||||
- **违反规则**:Web Interface Guidelines — Accessibility「Decorative elements need `aria-hidden`」
|
||||
- **改进建议**:所有装饰性 `<span>` 添加 `aria-hidden="true"`
|
||||
|
||||
### UI-04:图标缺少 `aria-hidden` 或 `aria-label`
|
||||
- **位置**:所有页面使用的 lucide-react 图标
|
||||
- **问题**:lucide 图标默认 `aria-hidden="true"`,但部分图标作为唯一内容(如按钮内仅图标)时需 `aria-label`
|
||||
- **现状**:本目录内图标均伴随文字,符合规范 ✅
|
||||
|
||||
### UI-05:表单缺少 `noValidate` 和客户端校验
|
||||
- **位置**:`student-courses-view.tsx:133`
|
||||
- **问题**:`<form action={handleJoin}>` 依赖 HTML5 原生校验(`required`),但未添加数字格式校验
|
||||
- **违反规则**:Web Interface Guidelines — Forms「Validate on client before submit」
|
||||
- **改进建议**:添加 `pattern="\d{6}"` 或自定义校验
|
||||
|
||||
### UI-06:链接缺少 `prefetch` 控制
|
||||
- **位置**:`learning/assignments/page.tsx:88, 110, 129, 151`、`student-courses-view.tsx:94, 100`
|
||||
- **问题**:`<Link href="/student/learning/assignments/...">` 默认 prefetch,但作业详情页数据量大,可能浪费带宽
|
||||
- **违反规则**:`bundle-preload`(轻度)
|
||||
- **改进建议**:对详情页链接添加 `prefetch={false}` 或在 hover 时预加载
|
||||
|
||||
### UI-07:`hover:shadow-md` 可能导致性能问题
|
||||
- **位置**:`learning/assignments/page.tsx:84, 125`、`student-courses-view.tsx:51`
|
||||
- **问题**:大量卡片同时绑定 `hover:shadow-md`,低端设备滚动时可能卡顿
|
||||
- **违反规则**:`rendering-content-visibility`(轻度)
|
||||
- **改进建议**:长列表添加 `content-visibility: auto` 或使用 `will-change: shadow`
|
||||
|
||||
### UI-08:颜色对比度待验证
|
||||
- **位置**:`text-muted-foreground` 在多处用于次要信息
|
||||
- **问题**:`text-muted-foreground` 在浅色模式下对比度可能不足 WCAG AA 标准(4.5:1)
|
||||
- **违反规则**:Web Interface Guidelines — Accessibility「Color contrast WCAG AA」
|
||||
- **改进建议**:使用工具验证 `--muted-foreground` 与 `--background` 的对比度
|
||||
|
||||
### UI-09:`tabular-nums` 使用正确 ✅
|
||||
- **位置**:`learning/assignments/page.tsx:107, 148`、`student-schedule-view.tsx:64`
|
||||
- **现状**:数字列使用 `tabular-nums`,对齐良好 ✅
|
||||
|
||||
### UI-10:响应式断点一致 ✅
|
||||
- **位置**:所有页面
|
||||
- **现状**:统一使用 `md:`、`lg:`、`xl:` 断点,符合规范 ✅
|
||||
|
||||
---
|
||||
|
||||
## 五、架构文档同步问题
|
||||
|
||||
### DOC-01:005 JSON 中 `/student/grades` 的 dataAccess 记录错误
|
||||
- **位置**:`docs/architecture/005_architecture_data.json:12047`
|
||||
- **问题**:记录为 `"grades/actions.getStudentGradeSummaryAction"`,实际代码调用 `grades/data-access.getStudentGradeSummary`
|
||||
- **改进建议**:修正为 `"grades/data-access.getStudentGradeSummary"`
|
||||
|
||||
### DOC-02:005 JSON 中 `/student/learning/textbooks/[id]` 的 type 错误
|
||||
- **位置**:`docs/architecture/005_architecture_data.json:12024`
|
||||
- **问题**:记录 `"type": "client"`,实际 `page.tsx` 是 Server Component(无 `"use client"`)
|
||||
- **改进建议**:改为 `"type": "server"`,并注明内部 `TextbookReader` 组件为 client
|
||||
|
||||
### DOC-03:005 JSON 中 `/student/diagnostic` 的 type 错误
|
||||
- **位置**:`docs/architecture/005_architecture_data.json:12063`
|
||||
- **问题**:记录 `"type": "client"`,实际 `page.tsx` 是 Server Component
|
||||
- **改进建议**:改为 `"type": "server"`,并注明内部 `StudentDiagnosticView` 组件为 client
|
||||
|
||||
### DOC-04:005 JSON 中 `/student/dashboard` 缺少 `getStudentSchedule` 记录
|
||||
- **位置**:`docs/architecture/005_architecture_data.json:11978-11982`
|
||||
- **问题**:实际代码调用了 `getStudentSchedule(student.id)`,但 dataAccess 数组未记录
|
||||
- **改进建议**:补充 `"classes/data-access.getStudentSchedule"`
|
||||
|
||||
### DOC-05:005 JSON 中 `/student/learning/assignments` 缺少 `getDemoStudentUser` 记录
|
||||
- **位置**:`docs/architecture/005_architecture_data.json:11989-11991`
|
||||
- **问题**:实际代码调用了 `getDemoStudentUser()`,但 dataAccess 数组未记录
|
||||
- **改进建议**:补充 `"homework/data-access.getDemoStudentUser"`(或迁移后更新为 `users/data-access.getCurrentStudentUser`)
|
||||
|
||||
### DOC-06:004 文档 2.26 节未记录 `loading.tsx` 文件
|
||||
- **位置**:`docs/architecture/004_architecture_impact_map.md:1109-1114`
|
||||
- **问题**:文件清单仅列出 3 个组件,未记录 `app/(dashboard)/student/*/loading.tsx`
|
||||
- **改进建议**:补充 loading.tsx 文件清单
|
||||
|
||||
### DOC-07:004 文档未记录 student 路由的认证模式不一致问题
|
||||
- **位置**:`docs/architecture/004_architecture_impact_map.md:1095-1115`
|
||||
- **问题**:2.26 节"已知问题"未提及三种认证模式混用
|
||||
- **改进建议**:在"已知问题"中补充 P1 项「认证模式不一致」
|
||||
|
||||
---
|
||||
|
||||
## 六、问题汇总统计
|
||||
|
||||
| 严重度 | 数量 | 问题编号 |
|
||||
|--------|------|----------|
|
||||
| 高 | 4 | BUG-A01, BUG-A02, BUG-A03, BUG-L01 |
|
||||
| 中 | 10 | BUG-L02, BUG-L04, BUG-L05, BUG-T01, BUG-T02, BUG-T03, BUG-TD01, BUG-TD02, BUG-D01, BUG-D02, BUG-E01, BUG-C01, BUG-X01, BUG-X02, BUG-X03 |
|
||||
| 低 | 6 | BUG-L03, BUG-L06, BUG-D03, BUG-S01, BUG-S02, BUG-E02, BUG-C02, BUG-C03, BUG-SV01 |
|
||||
| 性能 | 6 | PERF-01, PERF-02, PERF-03, PERF-04, PERF-05, PERF-06 |
|
||||
| 界面 | 8 | UI-01, UI-02, UI-03, UI-05, UI-06, UI-07, UI-08, UI-09 |
|
||||
| 文档 | 7 | DOC-01 ~ DOC-07 |
|
||||
| **合计** | **41** | |
|
||||
|
||||
---
|
||||
|
||||
## 七、修复优先级建议
|
||||
|
||||
### P0(立即修复 — 影响正确性和架构合规性)
|
||||
|
||||
1. **BUG-A01 + BUG-A02 + BUG-A03**:将 `getDemoStudentUser` 迁移到 `users/data-access.ts` 并重命名为 `getCurrentStudentUser()`,所有 student 页面统一使用 `getAuthContext()` 或新函数
|
||||
2. **BUG-E01**:`elective/page.tsx` 改用 `getAuthContext()`,移除 `import { auth } from "@/auth"`
|
||||
3. **BUG-L01**:`learning/assignments/page.tsx` 中英文混排修正
|
||||
4. **BUG-L03**:`learning/assignments/page.tsx:162` JSX 语法格式错误修正
|
||||
5. **BUG-T01**:`learning/textbooks/page.tsx` 删除注释代码
|
||||
|
||||
### P1(本迭代修复 — 影响可维护性和一致性)
|
||||
|
||||
6. **BUG-X01**:创建 `student/layout.tsx` 统一页面容器
|
||||
7. **UI-01**:为 8 个缺失路由添加 `loading.tsx`
|
||||
8. **UI-02**:创建 `student/error.tsx` 错误边界
|
||||
9. **BUG-L02**:抽取 `AssignmentCard` 组件消除重复
|
||||
10. **BUG-D01**:移除 `as` 类型断言
|
||||
11. **BUG-L04**:`getStatusVariant` 区分 submitted / in_progress
|
||||
12. **BUG-X02 + BUG-X03**:统一 "No user" 处理和图标选择
|
||||
13. **PERF-01 + PERF-02**:`student-courses-view.tsx` 使用 `useTransition` + memoize 卡片
|
||||
14. **PERF-04 + PERF-05**:合并重复 `filter` 遍历
|
||||
|
||||
### P2(下迭代修复 — 增强健壮性)
|
||||
|
||||
15. **BUG-L05 + BUG-L06**:类型精确化
|
||||
16. **BUG-T02 + BUG-D02**:补全页面标题
|
||||
17. **BUG-TD01 + BUG-TD03**:清理未使用变量 + `aria-hidden`
|
||||
18. **UI-03**:所有装饰性 `<span>` 添加 `aria-hidden`
|
||||
19. **UI-05**:表单客户端校验
|
||||
20. **BUG-C01 + BUG-C02 + BUG-C03**:`student-courses-view.tsx` 错误处理 + 表单优化
|
||||
21. **BUG-S01 + BUG-S02 + BUG-SV01**:小重构
|
||||
|
||||
### P3(文档同步)
|
||||
|
||||
22. **DOC-01 ~ DOC-07**:同步 004 和 005 架构文档
|
||||
|
||||
---
|
||||
|
||||
## 八、`student/layout.tsx` 推荐实现
|
||||
|
||||
```tsx
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
export default function StudentLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
> 注:`dashboard/page.tsx` 和 `learning/assignments/[assignmentId]/page.tsx` 若需不同 padding,可在页面内覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 九、`AssignmentCard` 组件推荐实现(消除 BUG-L02 重复)
|
||||
|
||||
```tsx
|
||||
import Link from "next/link"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import type { StudentHomeworkAssignment } from "@/modules/homework/types"
|
||||
|
||||
const getStatusVariant = (status: StudentHomeworkProgressStatus): "default" | "secondary" | "outline" => {
|
||||
switch (status) {
|
||||
case "graded": return "default"
|
||||
case "submitted": return "secondary"
|
||||
case "in_progress": return "outline" // BUG-L04 修复:区分 in_progress
|
||||
default: return "outline"
|
||||
}
|
||||
}
|
||||
|
||||
export function AssignmentCard({ assignment: a }: { assignment: StudentHomeworkAssignment }) {
|
||||
return (
|
||||
<Card className="flex h-full flex-col overflow-hidden transition-all hover:shadow-md">
|
||||
<CardHeader className="gap-2 pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<CardTitle className="text-base">
|
||||
<Link href={`/student/learning/assignments/${a.id}`} className="hover:underline">
|
||||
{a.title}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
|
||||
{getStatusLabel(a.progressStatus)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span>Due {a.dueAt ? formatDate(a.dueAt) : "-"}</span>
|
||||
<span className="px-2" aria-hidden="true">•</span>
|
||||
<span>Attempts {a.attemptsUsed}/{a.maxAttempts}</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="mt-auto flex items-center justify-between">
|
||||
<div className="text-sm">
|
||||
<div className="text-muted-foreground">Score</div>
|
||||
<div className="font-medium tabular-nums">{a.latestScore ?? "-"}</div>
|
||||
</div>
|
||||
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
|
||||
<Link href={`/student/learning/assignments/${a.id}`}>
|
||||
{getActionLabel(a.progressStatus)}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、验证命令
|
||||
|
||||
修复完成后应运行以下命令确保零错误:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npx tsc --noEmit
|
||||
npm run test:unit -- student
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> 报告生成人:AI Agent(GLM-5.2)
|
||||
> 核查方法:人工逐行审查 + 架构图比对 + 技能规则匹配
|
||||
> 应用技能:`vercel-react-best-practices`(性能优化)、`web-artifacts-builder`(界面构建参考)、`web-design-guidelines`(界面规范审查)
|
||||
741
bugs/teacher_bug.md
Normal file
741
bugs/teacher_bug.md
Normal file
@@ -0,0 +1,741 @@
|
||||
# `src/app/(dashboard)/teacher` 前端规范核查报告
|
||||
|
||||
> 核查日期:2026-06-18
|
||||
> 核查范围:`src/app/(dashboard)/teacher/` 目录下所有前端文件(page.tsx / loading.tsx)
|
||||
> 依据文档:项目规则、编码规范 `docs/standards/coding-standards.md`、架构影响地图 004、架构数据 005
|
||||
> 应用技能:`vercel-react-best-practices`(性能优化)、`web-artifacts-builder`(界面优化)、`web-design-guidelines`(Web 界面规范审查)
|
||||
|
||||
---
|
||||
|
||||
## 一、核查文件清单
|
||||
|
||||
| 文件 | 行数 | 类型 | 用途 |
|
||||
|------|------|------|------|
|
||||
| [dashboard/page.tsx](../src/app/(dashboard)/teacher/dashboard/page.tsx) | 37 | 页面 | 教师仪表盘 |
|
||||
| [attendance/page.tsx](../src/app/(dashboard)/teacher/attendance/page.tsx) | 83 | 页面 | 考勤记录列表 |
|
||||
| [attendance/sheet/page.tsx](../src/app/(dashboard)/teacher/attendance/sheet/page.tsx) | 49 | 页面 | 考勤登记 |
|
||||
| [attendance/stats/page.tsx](../src/app/(dashboard)/teacher/attendance/stats/page.tsx) | 120 | 页面 | 考勤统计 |
|
||||
| [classes/page.tsx](../src/app/(dashboard)/teacher/classes/page.tsx) | 5 | 页面 | 重定向到 my |
|
||||
| [classes/my/page.tsx](../src/app/(dashboard)/teacher/classes/my/page.tsx) | 18 | 页面 | 我的班级 |
|
||||
| [classes/my/[id]/page.tsx](../src/app/(dashboard)/teacher/classes/my/[id]/page.tsx) | 109 | 页面 | 班级详情 |
|
||||
| [classes/my/loading.tsx](../src/app/(dashboard)/teacher/classes/my/loading.tsx) | 31 | 加载态 | 班级列表骨架屏 |
|
||||
| [classes/schedule/page.tsx](../src/app/(dashboard)/teacher/classes/schedule/page.tsx) | 81 | 页面 | 班级课表 |
|
||||
| [classes/schedule/loading.tsx](../src/app/(dashboard)/teacher/classes/schedule/loading.tsx) | 28 | 加载态 | 课表骨架屏 |
|
||||
| [classes/students/page.tsx](../src/app/(dashboard)/teacher/classes/students/page.tsx) | 102 | 页面 | 学生列表 |
|
||||
| [classes/students/loading.tsx](../src/app/(dashboard)/teacher/classes/students/loading.tsx) | 20 | 加载态 | 学生列表骨架屏 |
|
||||
| [course-plans/page.tsx](../src/app/(dashboard)/teacher/course-plans/page.tsx) | 49 | 页面 | 课程计划列表 |
|
||||
| [course-plans/[id]/page.tsx](../src/app/(dashboard)/teacher/course-plans/[id]/page.tsx) | 26 | 页面 | 课程计划详情 |
|
||||
| [diagnostic/page.tsx](../src/app/(dashboard)/teacher/diagnostic/page.tsx) | 48 | 页面 | 学习诊断报告 |
|
||||
| [diagnostic/class/[classId]/page.tsx](../src/app/(dashboard)/teacher/diagnostic/class/[classId]/page.tsx) | 45 | 页面 | 班级诊断 |
|
||||
| [diagnostic/student/[studentId]/page.tsx](../src/app/(dashboard)/teacher/diagnostic/student/[studentId]/page.tsx) | 65 | 页面 | 学生诊断 |
|
||||
| [elective/page.tsx](../src/app/(dashboard)/teacher/elective/page.tsx) | 50 | 页面 | 选修课程 |
|
||||
| [exams/page.tsx](../src/app/(dashboard)/teacher/exams/page.tsx) | 5 | 页面 | 重定向到 all |
|
||||
| [exams/all/page.tsx](../src/app/(dashboard)/teacher/exams/all/page.tsx) | 148 | 页面 | 考试列表 |
|
||||
| [exams/all/loading.tsx](../src/app/(dashboard)/teacher/exams/all/loading.tsx) | 24 | 加载态 | 考试列表骨架屏 |
|
||||
| [exams/create/page.tsx](../src/app/(dashboard)/teacher/exams/create/page.tsx) | 10 | 页面 | 创建考试 |
|
||||
| [exams/create/loading.tsx](../src/app/(dashboard)/teacher/exams/create/loading.tsx) | 16 | 加载态 | 创建考试骨架屏 |
|
||||
| [exams/[id]/build/page.tsx](../src/app/(dashboard)/teacher/exams/[id]/build/page.tsx) | 120 | 页面 | 组卷 |
|
||||
| [exams/[id]/proctoring/page.tsx](../src/app/(dashboard)/teacher/exams/[id]/proctoring/page.tsx) | 55 | 页面 | 监考 |
|
||||
| [exams/grading/page.tsx](../src/app/(dashboard)/teacher/exams/grading/page.tsx) | 5 | 页面 | 重定向 |
|
||||
| [exams/grading/[submissionId]/page.tsx](../src/app/(dashboard)/teacher/exams/grading/[submissionId]/page.tsx) | 6 | 页面 | 重定向 |
|
||||
| [exams/grading/loading.tsx](../src/app/(dashboard)/teacher/exams/grading/loading.tsx) | 20 | 加载态 | 批改骨架屏 |
|
||||
| [grades/page.tsx](../src/app/(dashboard)/teacher/grades/page.tsx) | 101 | 页面 | 成绩管理 |
|
||||
| [grades/analytics/page.tsx](../src/app/(dashboard)/teacher/grades/analytics/page.tsx) | 259 | 页面 | 成绩分析 |
|
||||
| [grades/entry/page.tsx](../src/app/(dashboard)/teacher/grades/entry/page.tsx) | 52 | 页面 | 批量录入 |
|
||||
| [grades/stats/page.tsx](../src/app/(dashboard)/teacher/grades/stats/page.tsx) | 139 | 页面 | 成绩统计 |
|
||||
| [homework/page.tsx](../src/app/(dashboard)/teacher/homework/page.tsx) | 5 | 页面 | 重定向 |
|
||||
| [homework/assignments/page.tsx](../src/app/(dashboard)/teacher/homework/assignments/page.tsx) | 119 | 页面 | 作业列表 |
|
||||
| [homework/assignments/create/page.tsx](../src/app/(dashboard)/teacher/homework/assignments/create/page.tsx) | 43 | 页面 | 创建作业 |
|
||||
| [homework/assignments/[id]/page.tsx](../src/app/(dashboard)/teacher/homework/assignments/[id]/page.tsx) | 100 | 页面 | 作业详情 |
|
||||
| [homework/assignments/[id]/submissions/page.tsx](../src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx) | 86 | 页面 | 作业提交列表 |
|
||||
| [homework/submissions/page.tsx](../src/app/(dashboard)/teacher/homework/submissions/page.tsx) | 80 | 页面 | 提交审阅 |
|
||||
| [homework/submissions/[submissionId]/page.tsx](../src/app/(dashboard)/teacher/homework/submissions/[submissionId]/page.tsx) | 44 | 页面 | 批改详情 |
|
||||
| [questions/page.tsx](../src/app/(dashboard)/teacher/questions/page.tsx) | 120 | 页面 | 题库 |
|
||||
| [questions/loading.tsx](../src/app/(dashboard)/teacher/questions/loading.tsx) | 29 | 加载态 | 题库骨架屏 |
|
||||
| [schedule-changes/page.tsx](../src/app/(dashboard)/teacher/schedule-changes/page.tsx) | 69 | 页面 | 课表变更 |
|
||||
| [textbooks/page.tsx](../src/app/(dashboard)/teacher/textbooks/page.tsx) | 74 | 页面 | 教材列表 |
|
||||
| [textbooks/loading.tsx](../src/app/(dashboard)/teacher/textbooks/loading.tsx) | 48 | 加载态 | 教材骨架屏 |
|
||||
| [textbooks/[id]/page.tsx](../src/app/(dashboard)/teacher/textbooks/[id]/page.tsx) | 63 | 页面 | 教材详情 |
|
||||
| [textbooks/[id]/loading.tsx](../src/app/(dashboard)/teacher/textbooks/[id]/loading.tsx) | 66 | 加载态 | 教材详情骨架屏 |
|
||||
|
||||
共计 **45 个文件**(37 个 page.tsx + 8 个 loading.tsx)。
|
||||
|
||||
---
|
||||
|
||||
## 二、违规问题清单
|
||||
|
||||
### 2.1 架构分层违规 — 严重度:高
|
||||
|
||||
#### BUG-T01:app 层直接访问数据库(dashboard/page.tsx)
|
||||
- **位置**:[dashboard/page.tsx:4-6, 18-21](../src/app/(dashboard)/teacher/dashboard/page.tsx)
|
||||
- **问题**:页面直接 `import { db } from "@/shared/db"` 并调用 `db.query.users.findFirst()`,违反项目规则「`app/` 只能调用 `modules/` 的 Server Actions 和 data-access,不直接访问 DB」
|
||||
- **现状**:
|
||||
```typescript
|
||||
import { db } from "@/shared/db"
|
||||
import { users } from "@/shared/db/schema"
|
||||
// ...
|
||||
db.query.users.findFirst({
|
||||
where: eq(users.id, teacherId),
|
||||
columns: { name: true },
|
||||
})
|
||||
```
|
||||
- **改进建议**:通过 `modules/users/data-access.ts` 暴露 `getUserNameById(id)` 函数调用
|
||||
|
||||
#### BUG-T02:app 层直接访问数据库(grades/page.tsx)
|
||||
- **位置**:[grades/page.tsx:5-7, 35](../src/app/(dashboard)/teacher/grades/page.tsx)
|
||||
- **问题**:直接 `db.query.subjects.findMany()` 查询科目列表,违反三层架构
|
||||
- **改进建议**:在 `modules/school/data-access.ts` 或 `modules/grades/data-access.ts` 暴露 `getSubjects()` 函数
|
||||
|
||||
#### BUG-T03:app 层直接访问数据库(grades/analytics/page.tsx)
|
||||
- **位置**:[grades/analytics/page.tsx:5-6, 48-50](../src/app/(dashboard)/teacher/grades/analytics/page.tsx)
|
||||
- **问题**:同 BUG-T02,直接 `db.query.subjects.findMany()`
|
||||
- **改进建议**:同 BUG-T02
|
||||
|
||||
#### BUG-T04:app 层直接访问数据库(grades/entry/page.tsx)
|
||||
- **位置**:[grades/entry/page.tsx:1-3, 25](../src/app/(dashboard)/teacher/grades/entry/page.tsx)
|
||||
- **问题**:同 BUG-T02
|
||||
- **改进建议**:同 BUG-T02
|
||||
|
||||
#### BUG-T05:app 层直接访问数据库(grades/stats/page.tsx)
|
||||
- **位置**:[grades/stats/page.tsx:1-3, 28](../src/app/(dashboard)/teacher/grades/stats/page.tsx)
|
||||
- **问题**:同 BUG-T02
|
||||
- **改进建议**:同 BUG-T02
|
||||
|
||||
#### BUG-T06:认证上下文获取方式不一致
|
||||
- **位置**:
|
||||
- [course-plans/page.tsx:1, 23](../src/app/(dashboard)/teacher/course-plans/page.tsx)
|
||||
- [elective/page.tsx:1, 23](../src/app/(dashboard)/teacher/elective/page.tsx)
|
||||
- **问题**:使用 `import { auth } from "@/auth"` + `auth()` 获取 session,而其他页面统一使用 `getAuthContext()`(含 DataScope 解析)
|
||||
- **影响**:无法获得 `dataScope`,无法做数据范围过滤;与项目其他页面不一致
|
||||
- **改进建议**:统一改为 `const ctx = await getAuthContext(); const teacherId = ctx.userId`
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Prettier 配置违规 — 严重度:中
|
||||
|
||||
项目 `.prettierrc` 配置 `"semi": false`,但以下文件使用分号结尾:
|
||||
|
||||
#### BUG-T07:textbooks/page.tsx 使用分号
|
||||
- **位置**:[textbooks/page.tsx:3, 73](../src/app/(dashboard)/teacher/textbooks/page.tsx)
|
||||
- **问题**:`import { TextbookCard } from "...";` 等多处使用分号
|
||||
- **改进建议**:运行 `npx prettier --write` 统一格式
|
||||
|
||||
#### BUG-T08:textbooks/[id]/page.tsx 使用分号
|
||||
- **位置**:[textbooks/[id]/page.tsx](../src/app/(dashboard)/teacher/textbooks/[id]/page.tsx)(全文)
|
||||
- **问题**:多处语句使用分号结尾
|
||||
- **改进建议**:同 BUG-T07
|
||||
|
||||
#### BUG-T09:textbooks/loading.tsx 使用分号
|
||||
- **位置**:[textbooks/loading.tsx](../src/app/(dashboard)/teacher/textbooks/loading.tsx)(全文)
|
||||
- **问题**:同 BUG-T07
|
||||
- **改进建议**:同 BUG-T07
|
||||
|
||||
#### BUG-T10:textbooks/[id]/loading.tsx 使用分号
|
||||
- **位置**:[textbooks/[id]/loading.tsx](../src/app/(dashboard)/teacher/textbooks/[id]/loading.tsx)(全文)
|
||||
- **问题**:同 BUG-T07
|
||||
- **改进建议**:同 BUG-T07
|
||||
|
||||
---
|
||||
|
||||
### 2.3 TypeScript 规范违规 — 严重度:高
|
||||
|
||||
#### BUG-T11:使用 `as` 类型断言(exams/[id]/build/page.tsx)
|
||||
- **位置**:[exams/[id]/build/page.tsx:32-34](../src/app/(dashboard)/teacher/exams/[id]/build/page.tsx)
|
||||
- **问题**:使用 `as` 断言转换类型,违反编码规范「禁止 `as` 断言(除非从 `unknown` 转换)」
|
||||
- **现状**:
|
||||
```typescript
|
||||
content: q.content as Question["content"],
|
||||
type: q.type as Question["type"],
|
||||
```
|
||||
- **改进建议**:在 data-access 层返回正确类型,或使用类型守卫函数
|
||||
|
||||
#### BUG-T12:使用 `as` 类型断言(attendance/page.tsx)
|
||||
- **位置**:[attendance/page.tsx:39](../src/app/(dashboard)/teacher/attendance/page.tsx)
|
||||
- **问题**:`status as "present" | "absent" | "late" | "early_leave" | "excused"` 直接断言
|
||||
- **改进建议**:使用类型守卫函数 `isAttendanceStatus(value): value is AttendanceStatus`
|
||||
|
||||
#### BUG-T13:使用 `as` 类型断言(grades/page.tsx)
|
||||
- **位置**:[grades/page.tsx:43-44](../src/app/(dashboard)/teacher/grades/page.tsx)
|
||||
- **问题**:`type as "exam" | "quiz" | "homework" | "other"` 和 `semester as "1" | "2"` 直接断言
|
||||
- **改进建议**:使用类型守卫
|
||||
|
||||
#### BUG-T14:使用 `as` 类型断言(grades/analytics/page.tsx)
|
||||
- **位置**:[grades/analytics/page.tsx](../src/app/(dashboard)/teacher/grades/analytics/page.tsx)(多处)
|
||||
- **问题**:同上模式
|
||||
- **改进建议**:同上
|
||||
|
||||
#### BUG-T15:使用 `as` 类型断言(diagnostic/page.tsx)
|
||||
- **位置**:[diagnostic/page.tsx:27-28](../src/app/(dashboard)/teacher/diagnostic/page.tsx)
|
||||
- **问题**:`reportType as DiagnosticReportType` 和 `status as DiagnosticReportStatus`
|
||||
- **改进建议**:使用类型守卫
|
||||
|
||||
#### BUG-T16:函数返回值未显式标注(getParam 工具函数)
|
||||
- **位置**:以下 15 个文件中的 `getParam` 函数均未标注返回类型
|
||||
- attendance/page.tsx:15
|
||||
- attendance/sheet/page.tsx:9
|
||||
- attendance/stats/page.tsx:12
|
||||
- classes/schedule/page.tsx:14
|
||||
- classes/students/page.tsx:14
|
||||
- course-plans/page.tsx:10
|
||||
- diagnostic/page.tsx:10
|
||||
- elective/page.tsx:10
|
||||
- exams/all/page.tsx:16
|
||||
- grades/page.tsx:19
|
||||
- grades/analytics/page.tsx:28
|
||||
- grades/entry/page.tsx:12
|
||||
- grades/stats/page.tsx:15
|
||||
- homework/assignments/page.tsx:23
|
||||
- questions/page.tsx:15
|
||||
- textbooks/page.tsx:13
|
||||
- **问题**:违反编码规范「函数返回值必须显式标注,特别是 `Promise<T>`」
|
||||
- **现状**:`const getParam = (params: SearchParams, key: string) => { ... }`
|
||||
- **改进建议**:`const getParam = (params: SearchParams, key: string): string | undefined => { ... }`
|
||||
|
||||
#### BUG-T17:页面默认导出函数未标注返回类型
|
||||
- **位置**:所有 page.tsx 文件的 `export default async function XxxPage()`
|
||||
- **问题**:未标注 `Promise<JSX.Element>` 或 `Promise<React.ReactNode>`
|
||||
- **规范依据**:编码规范 5.2 示例 `export default async function UsersPage(): Promise<JSX.Element>`
|
||||
- **改进建议**:统一补充返回类型标注
|
||||
|
||||
---
|
||||
|
||||
### 2.4 DRY 违规(重复代码) — 严重度:中
|
||||
|
||||
#### BUG-T18:`getParam` 工具函数在 16 个文件中重复定义
|
||||
- **位置**:见 BUG-T16 列表
|
||||
- **问题**:完全相同的工具函数 `getParam` 和类型 `SearchParams` 在 16 个页面文件中复制粘贴
|
||||
- **改进建议**:提取到 `shared/lib/search-params.ts`:
|
||||
```typescript
|
||||
export type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
export function getParam(params: SearchParams, key: string): string | undefined {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
```
|
||||
|
||||
#### BUG-T19:`StatsClassSelector` 模式重复
|
||||
- **位置**:
|
||||
- [attendance/stats/page.tsx:91-119](../src/app/(dashboard)/teacher/attendance/stats/page.tsx)
|
||||
- [grades/stats/page.tsx:86-138](../src/app/(dashboard)/teacher/grades/stats/page.tsx)
|
||||
- [grades/analytics/page.tsx:150-258](../src/app/(dashboard)/teacher/grades/analytics/page.tsx)
|
||||
- **问题**:三处文件都定义了「类筛选按钮组」组件,结构几乎相同(`<a>` 标签 + 条件 className)
|
||||
- **改进建议**:提取为共享组件 `shared/components/ui/filter-chips.tsx`
|
||||
|
||||
---
|
||||
|
||||
### 2.5 性能问题(vercel-react-best-practices) — 严重度:高
|
||||
|
||||
#### BUG-T20:串行数据获取 waterfall(attendance/page.tsx)
|
||||
- **位置**:[attendance/page.tsx:32-41](../src/app/(dashboard)/teacher/attendance/page.tsx)
|
||||
- **问题**:`getTeacherClasses()` 与 `getAttendanceRecords()` 串行执行,但二者无依赖关系
|
||||
- **违反规则**:`async-parallel` - 独立操作应使用 `Promise.all()`
|
||||
- **改进建议**:
|
||||
```typescript
|
||||
const [classes, result] = await Promise.all([
|
||||
getTeacherClasses(),
|
||||
getAttendanceRecords({ ... }),
|
||||
])
|
||||
```
|
||||
|
||||
#### BUG-T21:串行数据获取 waterfall(attendance/sheet/page.tsx)
|
||||
- **位置**:[attendance/sheet/page.tsx:24-29](../src/app/(dashboard)/teacher/attendance/sheet/page.tsx)
|
||||
- **问题**:`getTeacherClasses()` 与 `getClassStudentsForAttendance()` 串行,但 students 依赖 defaultClassId(来自 searchParams),可与 classes 并行
|
||||
- **改进建议**:使用 `Promise.all` 并行
|
||||
|
||||
#### BUG-T22:串行数据获取 waterfall(attendance/stats/page.tsx)
|
||||
- **位置**:[attendance/stats/page.tsx:28-53](../src/app/(dashboard)/teacher/attendance/stats/page.tsx)
|
||||
- **问题**:`getTeacherClasses()` → `getClassAttendanceStats()` 串行,但 stats 依赖 classId(可从 classes[0] 取默认),可优化
|
||||
- **改进建议**:先并行获取 classes,再取 targetClassId 后获取 stats(当前逻辑合理但可考虑预取)
|
||||
|
||||
#### BUG-T23:串行数据获取 waterfall(grades/page.tsx)
|
||||
- **位置**:[grades/page.tsx:33-45](../src/app/(dashboard)/teacher/grades/page.tsx)
|
||||
- **问题**:`Promise.all([getTeacherClasses, db.query])` 之后串行 `getGradeRecords`,但 `getGradeRecords` 不依赖前两者结果
|
||||
- **改进建议**:三个查询全部 `Promise.all`
|
||||
|
||||
#### BUG-T24:串行数据获取 waterfall(grades/entry/page.tsx)
|
||||
- **位置**:[grades/entry/page.tsx:23-34](../src/app/(dashboard)/teacher/grades/entry/page.tsx)
|
||||
- **问题**:`Promise.all([getTeacherClasses, db.query])` 后串行 `getClassStudentsForEntry`,但 students 依赖 defaultClassId(来自 searchParams),可并行
|
||||
- **改进建议**:`Promise.all` 三个查询
|
||||
|
||||
#### BUG-T25:串行数据获取 waterfall(grades/stats/page.tsx)
|
||||
- **位置**:[grades/stats/page.tsx:26-54](../src/app/(dashboard)/teacher/grades/stats/page.tsx)
|
||||
- **问题**:`Promise.all([getTeacherClasses, db.query])` → `Promise.all([stats, ranking])` 两段串行
|
||||
- **改进建议**:合并为单个 `Promise.all`
|
||||
|
||||
#### BUG-T26:串行数据获取 waterfall(classes/my/[id]/page.tsx)
|
||||
- **位置**:[classes/my/[id]/page.tsx:21-30](../src/app/(dashboard)/teacher/classes/my/[id]/page.tsx)
|
||||
- **问题**:`Promise.all([insights, students, schedule])` 后串行 `getClassStudentSubjectScoresV2`
|
||||
- **改进建议**:将 `getClassStudentSubjectScoresV2` 加入第一个 `Promise.all`
|
||||
|
||||
#### BUG-T27:串行数据获取 waterfall(diagnostic/student/[studentId]/page.tsx)
|
||||
- **位置**:[diagnostic/student/[studentId]/page.tsx:30-45](../src/app/(dashboard)/teacher/diagnostic/student/[studentId]/page.tsx)
|
||||
- **问题**:`Promise.all([summary, reports])` 后串行 `getKnowledgePointStats()`
|
||||
- **改进建议**:合并为单个 `Promise.all`
|
||||
|
||||
#### BUG-T28:串行数据获取 waterfall(exams/[id]/build/page.tsx)
|
||||
- **位置**:[exams/[id]/build/page.tsx:12-26](../src/app/(dashboard)/teacher/exams/[id]/build/page.tsx)
|
||||
- **问题**:`getExamById` → `getQuestions` → `getQuestions(ids)` 三段串行
|
||||
- **改进建议**:前两个可并行;第三个依赖 exam.questions 的 ID 列表,需串行但可优化
|
||||
|
||||
#### BUG-T29:Bundle 优化 - barrel imports(lucide-react)
|
||||
- **位置**:几乎所有页面文件
|
||||
- **问题**:`import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"` 使用 barrel 文件导入,违反 `bundle-barrel-imports` 规则
|
||||
- **改进建议**:lucide-react 已支持 tree-shaking,但可考虑使用 `lucide-react/icons` 直接导入路径
|
||||
|
||||
#### BUG-T30:缺少 `export const dynamic = "force-dynamic"` 声明
|
||||
- **位置**:
|
||||
- [exams/all/page.tsx](../src/app/(dashboard)/teacher/exams/all/page.tsx)(使用 Suspense,可省略)
|
||||
- [exams/create/page.tsx](../src/app/(dashboard)/teacher/exams/create/page.tsx)
|
||||
- [exams/[id]/build/page.tsx](../src/app/(dashboard)/teacher/exams/[id]/build/page.tsx)
|
||||
- [questions/page.tsx](../src/app/(dashboard)/teacher/questions/page.tsx)(使用 Suspense)
|
||||
- [textbooks/page.tsx](../src/app/(dashboard)/teacher/textbooks/page.tsx)(使用 Suspense)
|
||||
- **问题**:动态数据页面未声明 `force-dynamic`,可能导致静态生成尝试失败
|
||||
- **改进建议**:所有含动态数据的页面统一添加 `export const dynamic = "force-dynamic"`
|
||||
|
||||
---
|
||||
|
||||
### 2.6 Web 界面规范违规(web-design-guidelines) — 严重度:中
|
||||
|
||||
#### BUG-T31:`<a>` 标签缺少 focus-visible 焦点样式
|
||||
- **位置**:
|
||||
- [attendance/stats/page.tsx:106-117](../src/app/(dashboard)/teacher/attendance/stats/page.tsx)
|
||||
- [grades/analytics/page.tsx:192-253](../src/app/(dashboard)/teacher/grades/analytics/page.tsx)
|
||||
- [grades/stats/page.tsx:100-135](../src/app/(dashboard)/teacher/grades/stats/page.tsx)
|
||||
- **问题**:筛选按钮使用 `<a>` 标签但仅有 `hover:bg-accent`,缺少 `focus-visible:ring-*` 或 `focus-visible:outline` 焦点样式
|
||||
- **违反规则**:Focus States - Interactive elements need visible focus
|
||||
- **改进建议**:添加 `focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2`
|
||||
|
||||
#### BUG-T32:`<a>` 标签作为筛选按钮语义不当
|
||||
- **位置**:同 BUG-T31
|
||||
- **问题**:筛选操作使用 `<a>` 标签导航到带 query 的 URL,虽然支持 Cmd/Ctrl+click,但视觉上是按钮形态,应使用 `<button>` 或添加 `role="button"`
|
||||
- **违反规则**:`<button>` for actions, `<a>`/`<Link>` for navigation
|
||||
- **改进建议**:使用 Next.js `<Link>` 并补充焦点样式,或改为 `<button>` + `useRouter` + `useSearchParams`
|
||||
|
||||
#### BUG-T33:标题层级缺失(exams/[id]/build/page.tsx)
|
||||
- **位置**:[exams/[id]/build/page.tsx:104-118](../src/app/(dashboard)/teacher/exams/[id]/build/page.tsx)
|
||||
- **问题**:页面无 `<h1>` 标题,直接渲染 `<ExamAssembly>` 组件,违反「Headings hierarchical `<h1>`–`<h6>`」
|
||||
- **改进建议**:在页面顶部添加 `<h1>` 标题(如「Build Exam」)
|
||||
|
||||
#### BUG-T34:标题层级缺失(exams/[id]/proctoring/page.tsx)
|
||||
- **位置**:[exams/[id]/proctoring/page.tsx:50-54](../src/app/(dashboard)/teacher/exams/[id]/proctoring/page.tsx)
|
||||
- **问题**:同 BUG-T33,无 `<h1>`
|
||||
- **改进建议**:同 BUG-T33
|
||||
|
||||
#### BUG-T35:标题层级缺失(classes/my/[id]/page.tsx)
|
||||
- **位置**:[classes/my/[id]/page.tsx:65-108](../src/app/(dashboard)/teacher/classes/my/[id]/page.tsx)
|
||||
- **问题**:页面无 `<h1>`,依赖 `<ClassHeader>` 组件渲染标题,需确认组件内是否有 h1
|
||||
- **改进建议**:确认 `ClassHeader` 包含 `<h1>`
|
||||
|
||||
#### BUG-T36:长文本未截断(homework/assignments/page.tsx)
|
||||
- **位置**:[homework/assignments/page.tsx:99-101](../src/app/(dashboard)/teacher/homework/assignments/page.tsx)
|
||||
- **问题**:作业标题 `<Link>{a.title}</Link>` 未限制长度,长标题会破坏表格布局
|
||||
- **违反规则**:Content Handling - Text containers handle long content
|
||||
- **改进建议**:添加 `line-clamp-2` 或 `truncate max-w-[200px]`
|
||||
|
||||
#### BUG-T37:长文本未截断(homework/submissions/page.tsx)
|
||||
- **位置**:[homework/submissions/page.tsx:58-60](../src/app/(dashboard)/teacher/homework/submissions/page.tsx)
|
||||
- **问题**:同 BUG-T36
|
||||
- **改进建议**:同 BUG-T36
|
||||
|
||||
#### BUG-T38:长文本未截断(homework/assignments/[id]/submissions/page.tsx)
|
||||
- **位置**:[homework/assignments/[id]/submissions/page.tsx:65](../src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx)
|
||||
- **问题**:学生姓名单元格未限制长度
|
||||
- **改进建议**:添加 `truncate max-w-[160px]`
|
||||
|
||||
#### BUG-T39:Flex 子元素缺少 `min-w-0`
|
||||
- **位置**:
|
||||
- [homework/assignments/[id]/page.tsx:26-42](../src/app/(dashboard)/teacher/homework/assignments/[id]/page.tsx)
|
||||
- [classes/my/[id]/page.tsx:86-104](../src/app/(dashboard)/teacher/classes/my/[id]/page.tsx)
|
||||
- **问题**:flex 容器内的文本子元素未设置 `min-w-0`,长内容无法正确截断
|
||||
- **违反规则**:Flex children need `min-w-0` to allow text truncation
|
||||
- **改进建议**:在 flex 子元素添加 `min-w-0`
|
||||
|
||||
#### BUG-T40:使用 `transition: all` 或 `transition-colors` 未列明属性
|
||||
- **位置**:
|
||||
- [attendance/stats/page.tsx:109](../src/app/(dashboard)/teacher/attendance/stats/page.tsx) - `transition-colors`(可接受)
|
||||
- [grades/analytics/page.tsx:195](../src/app/(dashboard)/teacher/grades/analytics/page.tsx) - `transition-colors`(可接受)
|
||||
- **问题**:`transition-colors` 实际上列明了属性,符合规范;但需检查是否有 `transition: all` 使用
|
||||
- **现状**:未发现 `transition: all`,此项通过
|
||||
|
||||
#### BUG-T41:硬编码日期/数字格式
|
||||
- **位置**:所有使用 `formatDate` 的文件
|
||||
- **问题**:需确认 `formatDate` 内部是否使用 `Intl.DateTimeFormat`,若使用硬编码格式则违规
|
||||
- **违反规则**:Locale & i18n - Dates/times: use `Intl.DateTimeFormat`
|
||||
- **改进建议**:检查 `shared/lib/utils.ts` 的 `formatDate` 实现
|
||||
|
||||
#### BUG-T42:数字列未使用 `tabular-nums`
|
||||
- **位置**:
|
||||
- [exams/all/page.tsx:54-60](../src/app/(dashboard)/teacher/exams/all/page.tsx) - 考试计数
|
||||
- [homework/submissions/page.tsx:69-71](../src/app/(dashboard)/teacher/homework/submissions/page.tsx) - 计数列
|
||||
- [homework/assignments/[id]/submissions/page.tsx:73](../src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx) - 分数
|
||||
- **问题**:数字列未使用 `font-variant-numeric: tabular-nums`,对齐不整齐
|
||||
- **违反规则**:Typography - `font-variant-numeric: tabular-nums` for number columns
|
||||
- **改进建议**:数字单元格添加 `tabular-nums` 类
|
||||
|
||||
#### BUG-T43:大列表未虚拟化
|
||||
- **位置**:
|
||||
- [questions/page.tsx:42](../src/app/(dashboard)/teacher/questions/page.tsx) - `pageSize: 200`
|
||||
- [exams/all/page.tsx](../src/app/(dashboard)/teacher/exams/all/page.tsx) - ExamDataTable
|
||||
- **问题**:题库页面一次加载 200 条题目,若渲染全部 DOM 节点会卡顿
|
||||
- **违反规则**:Performance - Large lists (>50 items): virtualize
|
||||
- **改进建议**:使用 `virtua` 或 `content-visibility: auto` 虚拟化长列表
|
||||
|
||||
---
|
||||
|
||||
### 2.7 组件规范违规 — 严重度:中
|
||||
|
||||
#### BUG-T44:不必要的包装组件(classes/my/page.tsx)
|
||||
- **位置**:[classes/my/page.tsx:6-17](../src/app/(dashboard)/teacher/classes/my/page.tsx)
|
||||
- **问题**:默认导出 `MyClassesPage` 仅调用 `MyClassesPageImpl`,多此一举
|
||||
- **现状**:
|
||||
```typescript
|
||||
export default function MyClassesPage() {
|
||||
return <MyClassesPageImpl />
|
||||
}
|
||||
|
||||
async function MyClassesPageImpl() {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
- **改进建议**:直接默认导出 async 函数:
|
||||
```typescript
|
||||
export default async function MyClassesPage() {
|
||||
const [classes, subjectOptions] = await Promise.all([...])
|
||||
return <MyClassesGrid classes={classes} subjectOptions={subjectOptions} />
|
||||
}
|
||||
```
|
||||
|
||||
#### BUG-T45:非导出组件定义在 page.tsx 中
|
||||
- **位置**:
|
||||
- [attendance/stats/page.tsx:91-119](../src/app/(dashboard)/teacher/attendance/stats/page.tsx) - `StatsClassSelector`
|
||||
- [grades/analytics/page.tsx:150-258](../src/app/(dashboard)/teacher/grades/analytics/page.tsx) - `AnalyticsFilters`
|
||||
- [grades/stats/page.tsx:86-138](../src/app/(dashboard)/teacher/grades/stats/page.tsx) - `StatsClassSelector`
|
||||
- [classes/schedule/page.tsx:45-63](../src/app/(dashboard)/teacher/classes/schedule/page.tsx) - `ScheduleResultsFallback`
|
||||
- [classes/students/page.tsx:68-81](../src/app/(dashboard)/teacher/classes/students/page.tsx) - `StudentsResultsFallback`
|
||||
- [exams/all/page.tsx:101-128](../src/app/(dashboard)/teacher/exams/all/page.tsx) - `ExamsResultsFallback`
|
||||
- [questions/page.tsx:75-88](../src/app/(dashboard)/teacher/questions/page.tsx) - `QuestionBankResultsFallback`
|
||||
- **问题**:辅助组件定义在 page.tsx 中,违反「其余所有组件使用具名导出」规范,且无法复用
|
||||
- **改进建议**:提取到 `components/` 目录或 `shared/components/ui/`
|
||||
|
||||
#### BUG-T46:exams/create/page.tsx 顶部多余空行
|
||||
- **位置**:[exams/create/page.tsx:5](../src/app/(dashboard)/teacher/exams/create/page.tsx)
|
||||
- **问题**:JSX 开始标签前有多余空行
|
||||
- **现状**:
|
||||
```typescript
|
||||
return (
|
||||
|
||||
<div className="...">
|
||||
```
|
||||
- **改进建议**:删除空行
|
||||
|
||||
---
|
||||
|
||||
### 2.8 安全与权限违规 — 严重度:高
|
||||
|
||||
#### BUG-T47:缺少权限校验(course-plans/page.tsx)
|
||||
- **位置**:[course-plans/page.tsx](../src/app/(dashboard)/teacher/course-plans/page.tsx)
|
||||
- **问题**:仅通过 `auth()` 获取 session,未调用 `requirePermission()` 或 `getAuthContext()` 进行权限校验
|
||||
- **改进建议**:使用 `getAuthContext()` 替代 `auth()`,并在 data-access 层做 DataScope 过滤
|
||||
|
||||
#### BUG-T48:缺少权限校验(elective/page.tsx)
|
||||
- **位置**:[elective/page.tsx](../src/app/(dashboard)/teacher/elective/page.tsx)
|
||||
- **问题**:同 BUG-T47
|
||||
- **改进建议**:同 BUG-T47
|
||||
|
||||
#### BUG-T49:缺少权限校验(dashboard/page.tsx)
|
||||
- **位置**:[dashboard/page.tsx](../src/app/(dashboard)/teacher/dashboard/page.tsx)
|
||||
- **问题**:依赖路由层代理(proxy.ts)做角色路由,但页面本身未做二次权限校验
|
||||
- **改进建议**:添加 `getAuthContext()` 确认教师身份
|
||||
|
||||
#### BUG-T50:权限校验方式不一致
|
||||
- **位置**:
|
||||
- [exams/[id]/proctoring/page.tsx:21](../src/app/(dashboard)/teacher/exams/[id]/proctoring/page.tsx) - 使用 `requirePermission(Permissions.EXAM_PROCTOR)`
|
||||
- [diagnostic/class/[classId]/page.tsx:15-23](../src/app/(dashboard)/teacher/diagnostic/class/[classId]/page.tsx) - 使用 `getAuthContext()` + DataScope 校验
|
||||
- [grades/page.tsx:26](../src/app/(dashboard)/teacher/grades/page.tsx) - 使用 `getAuthContext()`
|
||||
- **问题**:权限校验方式不统一,部分用 `requirePermission`,部分用 `getAuthContext`,部分无校验
|
||||
- **改进建议**:统一权限校验策略,页面入口用 `getAuthContext()`,写操作用 `requirePermission()`
|
||||
|
||||
---
|
||||
|
||||
### 2.9 加载态缺失 — 严重度:低
|
||||
|
||||
#### BUG-T51:缺少 loading.tsx 的目录
|
||||
- **位置**:
|
||||
- `attendance/`(含 sheet/、stats/)
|
||||
- `course-plans/`(含 [id]/)
|
||||
- `diagnostic/`(含 class/、student/)
|
||||
- `elective/`
|
||||
- `exams/[id]/`(含 build/、proctoring/)
|
||||
- `grades/`(含 analytics/、entry/、stats/)
|
||||
- `homework/`(含 assignments/、submissions/)
|
||||
- `schedule-changes/`
|
||||
- **问题**:以上目录无 `loading.tsx`,导航时无骨架屏反馈
|
||||
- **改进建议**:为每个动态页面目录添加 `loading.tsx`,参考 `classes/my/loading.tsx` 模式
|
||||
|
||||
#### BUG-T52:exams/grading/loading.tsx 实际无用
|
||||
- **位置**:[exams/grading/loading.tsx](../src/app/(dashboard)/teacher/exams/grading/loading.tsx)
|
||||
- **问题**:`exams/grading/page.tsx` 仅做 `redirect()`,loading.tsx 永远不会显示
|
||||
- **改进建议**:删除该 loading.tsx
|
||||
|
||||
---
|
||||
|
||||
### 2.10 逻辑与代码质量问题 — 严重度:中
|
||||
|
||||
#### BUG-T53:homework/assignments/page.tsx 条件取数逻辑反直觉
|
||||
- **位置**:[homework/assignments/page.tsx:33-36](../src/app/(dashboard)/teacher/homework/assignments/page.tsx)
|
||||
- **问题**:`classId && classId !== "all" ? getTeacherClasses() : Promise.resolve([])` 仅在有 classId 时才获取班级列表,逻辑反直觉(通常应始终获取班级列表用于筛选下拉)
|
||||
- **现状**:classes 仅用于查找 className 显示,逻辑正确但可读性差
|
||||
- **改进建议**:始终获取 classes,或添加注释说明「仅在过滤时需要 className」
|
||||
|
||||
#### BUG-T54:exams/[id]/build/page.tsx `normalizeStructure` 函数过长
|
||||
- **位置**:[exams/[id]/build/page.tsx:52-91](../src/app/(dashboard)/teacher/exams/[id]/build/page.tsx)
|
||||
- **问题**:40 行的 `normalizeStructure` 函数定义在组件内部,包含嵌套递归逻辑,可读性差
|
||||
- **改进建议**:提取到 `modules/exams/utils/normalize-structure.ts`,并添加单元测试
|
||||
|
||||
#### BUG-T55:exams/[id]/build/page.tsx 使用 `satisfies` 但混合 `as`
|
||||
- **位置**:[exams/[id]/build/page.tsx:74, 84, 86](../src/app/(dashboard)/teacher/exams/[id]/build/page.tsx)
|
||||
- **问题**:同时使用 `satisfies ExamNode`(好)和 `as ExamNode[]`(违规),类型处理不一致
|
||||
- **改进建议**:移除 `as ExamNode[]`,改用类型守卫或 `Array.from()` 配合 filter
|
||||
|
||||
#### BUG-T56:grades/analytics/page.tsx 文件过长
|
||||
- **位置**:[grades/analytics/page.tsx](../src/app/(dashboard)/teacher/grades/analytics/page.tsx) - 259 行
|
||||
- **问题**:单文件 259 行,接近 React 组件 500 行建议上限的 50%,包含页面 + `AnalyticsFilters` 组件
|
||||
- **改进建议**:将 `AnalyticsFilters` 提取到 `modules/grades/components/analytics-filters.tsx`
|
||||
|
||||
#### BUG-T57:exams/all/page.tsx 缺少 `export const dynamic`
|
||||
- **位置**:[exams/all/page.tsx](../src/app/(dashboard)/teacher/exams/all/page.tsx)
|
||||
- **问题**:使用 Suspense 但未声明 `force-dynamic`,可能导致构建时尝试静态生成
|
||||
- **改进建议**:添加 `export const dynamic = "force-dynamic"`
|
||||
|
||||
---
|
||||
|
||||
### 2.11 可访问性问题 — 严重度:中
|
||||
|
||||
#### BUG-T58:图标按钮缺少 aria-label
|
||||
- **位置**:
|
||||
- [textbooks/[id]/page.tsx:33-36](../src/app/(dashboard)/teacher/textbooks/[id]/page.tsx) - 返回按钮
|
||||
- [homework/assignments/[id]/page.tsx:28-31](../src/app/(dashboard)/teacher/homework/assignments/[id]/page.tsx) - 面包屑链接(有文本,OK)
|
||||
- **问题**:`textbooks/[id]/page.tsx` 的返回按钮仅含图标,无 `aria-label`
|
||||
- **违反规则**:Accessibility - Icon-only buttons need `aria-label`
|
||||
- **改进建议**:添加 `aria-label="Back to textbooks"`
|
||||
|
||||
#### BUG-T59:装饰性图标未标记 aria-hidden
|
||||
- **位置**:几乎所有页面中的 lucide 图标
|
||||
- **问题**:如 `<BarChart3 className="mr-2 h-4 w-4" />` 等装饰性图标未添加 `aria-hidden="true"`
|
||||
- **违反规则**:Accessibility - Decorative icons need `aria-hidden="true"`
|
||||
- **改进建议**:装饰性图标添加 `aria-hidden="true"`
|
||||
|
||||
#### BUG-T60:缺少 skip link
|
||||
- **位置**:所有页面
|
||||
- **问题**:页面无「跳到主内容」的 skip link,键盘用户需 Tab 遍历整个侧边栏
|
||||
- **违反规则**:Accessibility - include skip link for main content
|
||||
- **改进建议**:在 dashboard layout 添加 skip link(应在 layout 层处理)
|
||||
|
||||
---
|
||||
|
||||
### 2.12 其他问题
|
||||
|
||||
#### BUG-T61:homework/assignments/[id]/page.tsx 使用 h1 但其他页面用 h2
|
||||
- **位置**:
|
||||
- [homework/assignments/[id]/page.tsx:36](../src/app/(dashboard)/teacher/homework/assignments/[id]/page.tsx) - `<h1>`
|
||||
- [attendance/page.tsx:47](../src/app/(dashboard)/teacher/attendance/page.tsx) - `<h2>`
|
||||
- [grades/page.tsx:54](../src/app/(dashboard)/teacher/grades/page.tsx) - `<h2>`
|
||||
- **问题**:页面主标题层级不统一,部分用 h1,部分用 h2
|
||||
- **改进建议**:统一使用 h1 作为页面主标题(layout 可能已有 h1,需确认)
|
||||
|
||||
#### BUG-T62:textbooks/page.tsx 使用 h1,其他页面用 h2
|
||||
- **位置**:
|
||||
- [textbooks/page.tsx:57](../src/app/(dashboard)/teacher/textbooks/page.tsx) - `<h1>`
|
||||
- [textbooks/[id]/page.tsx:45](../src/app/(dashboard)/teacher/textbooks/[id]/page.tsx) - `<h1>`
|
||||
- **问题**:同 BUG-T61,标题层级不统一
|
||||
- **改进建议**:统一标题层级策略
|
||||
|
||||
#### BUG-T63:exams/create/page.tsx 缺少页面标题
|
||||
- **位置**:[exams/create/page.tsx:3-9](../src/app/(dashboard)/teacher/exams/create/page.tsx)
|
||||
- **问题**:页面无任何标题,直接渲染表单
|
||||
- **改进建议**:添加 `<h1>Create Exam</h1>`
|
||||
|
||||
#### BUG-T64:loading.tsx 文件命名风格不一致
|
||||
- **位置**:
|
||||
- [textbooks/loading.tsx](../src/app/(dashboard)/teacher/textbooks/loading.tsx) - 使用 Card 组件
|
||||
- [classes/my/loading.tsx](../src/app/(dashboard)/teacher/classes/my/loading.tsx) - 使用纯 div
|
||||
- **问题**:骨架屏风格不统一,部分用 Card 组件,部分用纯 div
|
||||
- **改进建议**:统一骨架屏风格,提取共享骨架屏组件
|
||||
|
||||
---
|
||||
|
||||
## 三、改进优先级汇总
|
||||
|
||||
### P0 - 立即修复(架构与安全)
|
||||
|
||||
| BUG ID | 问题 | 影响 |
|
||||
|--------|------|------|
|
||||
| T01-T05 | app 层直接访问 DB | 破坏三层架构,模块封装失效 |
|
||||
| T06 | 认证方式不一致 | 数据范围过滤缺失 |
|
||||
| T47-T50 | 权限校验缺失/不一致 | 越权访问风险 |
|
||||
|
||||
### P1 - 高优先级(TypeScript 与性能)
|
||||
|
||||
| BUG ID | 问题 | 影响 |
|
||||
|--------|------|------|
|
||||
| T11-T15 | 使用 `as` 类型断言 | 类型安全受损 |
|
||||
| T16-T17 | 函数返回值未标注 | 类型推导不显式 |
|
||||
| T20-T28 | 串行数据获取 waterfall | 页面加载性能差 |
|
||||
| T43 | 大列表未虚拟化 | 题库页面卡顿 |
|
||||
|
||||
### P2 - 中优先级(规范与可访问性)
|
||||
|
||||
| BUG ID | 问题 | 影响 |
|
||||
|--------|------|------|
|
||||
| T07-T10 | Prettier 分号违规 | 代码风格不一致 |
|
||||
| T18-T19 | DRY 违规 | 维护成本高 |
|
||||
| T31-T32 | 筛选按钮焦点样式/语义 | 键盘可访问性差 |
|
||||
| T36-T39 | 长文本未截断 | 布局破坏风险 |
|
||||
| T42 | 数字列未用 tabular-nums | 数字对齐不整齐 |
|
||||
| T58-T60 | 可访问性缺失 | 屏幕阅读器体验差 |
|
||||
|
||||
### P3 - 低优先级(代码质量)
|
||||
|
||||
| BUG ID | 问题 | 影响 |
|
||||
|--------|------|------|
|
||||
| T44-T46 | 组件定义问题 | 可读性差 |
|
||||
| T51-T52 | loading.tsx 缺失/冗余 | 用户体验不一致 |
|
||||
| T53-T57 | 逻辑与长度问题 | 可维护性 |
|
||||
| T61-T64 | 标题层级与风格 | 一致性 |
|
||||
|
||||
---
|
||||
|
||||
## 四、推荐改进方案
|
||||
|
||||
### 4.1 提取共享工具(解决 T16, T18)
|
||||
|
||||
新建 `src/shared/lib/search-params.ts`:
|
||||
|
||||
```typescript
|
||||
export type SearchParams = { [key: string]: string | string[] | undefined }
|
||||
|
||||
export function getParam(params: SearchParams, key: string): string | undefined {
|
||||
const v = params[key]
|
||||
return Array.isArray(v) ? v[0] : v
|
||||
}
|
||||
```
|
||||
|
||||
所有页面统一 `import { getParam, type SearchParams } from "@/shared/lib/search-params"`。
|
||||
|
||||
### 4.2 提取共享筛选组件(解决 T19, T31, T32)
|
||||
|
||||
新建 `src/shared/components/ui/filter-chips.tsx`:
|
||||
|
||||
```tsx
|
||||
import Link from "next/link"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
interface FilterChip {
|
||||
id: string
|
||||
label: string
|
||||
href: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export function FilterChips({ chips }: { chips: FilterChip[] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{chips.map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
href={c.href}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-1.5 text-sm transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
c.active
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "bg-card hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
{c.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 统一权限校验模式(解决 T47-T50)
|
||||
|
||||
所有教师页面入口统一:
|
||||
|
||||
```typescript
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
|
||||
export default async function XxxPage() {
|
||||
const ctx = await getAuthContext()
|
||||
// 使用 ctx.userId、ctx.dataScope 进行数据过滤
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 并行数据获取优化(解决 T20-T28)
|
||||
|
||||
将串行 `await` 改为 `Promise.all`:
|
||||
|
||||
```typescript
|
||||
// 优化前
|
||||
const classes = await getTeacherClasses()
|
||||
const records = await getGradeRecords({ ... })
|
||||
|
||||
// 优化后
|
||||
const [classes, records] = await Promise.all([
|
||||
getTeacherClasses(),
|
||||
getGradeRecords({ ... }),
|
||||
])
|
||||
```
|
||||
|
||||
### 4.5 DB 访问下沉到 data-access(解决 T01-T05)
|
||||
|
||||
在 `modules/school/data-access.ts` 添加:
|
||||
|
||||
```typescript
|
||||
import "server-only"
|
||||
import { db } from "@/shared/db"
|
||||
import { subjects } from "@/shared/db/schema"
|
||||
import { asc } from "drizzle-orm"
|
||||
|
||||
export async function getSubjectsOrdered(): Promise<Subject[]> {
|
||||
return db.query.subjects.findMany({
|
||||
orderBy: [asc(subjects.order), asc(subjects.name)],
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
页面改为 `import { getSubjectsOrdered } from "@/modules/school/data-access"`。
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步建议
|
||||
|
||||
本次核查未修改源码,无需同步架构图。但建议在后续修复时:
|
||||
|
||||
1. 若新增 `shared/lib/search-params.ts`,需在 005_architecture_data.json 的 `shared.lib.exports` 中添加
|
||||
2. 若新增 `shared/components/ui/filter-chips.tsx`,需在 005 的 `shared.components.exports` 中添加
|
||||
3. 若 `modules/school/data-access.ts` 新增 `getSubjectsOrdered`,需在 005 的 `modules.school.dataAccess` 中添加
|
||||
|
||||
---
|
||||
|
||||
## 六、总结
|
||||
|
||||
本次核查覆盖 `src/app/(dashboard)/teacher/` 下全部 45 个前端文件,共发现 **64 个问题**,分布如下:
|
||||
|
||||
| 严重度 | 数量 | 类别 |
|
||||
|--------|------|------|
|
||||
| P0 | 9 | 架构违规、权限缺失 |
|
||||
| P1 | 16 | TypeScript、性能 |
|
||||
| P2 | 18 | 规范、可访问性 |
|
||||
| P3 | 21 | 代码质量 |
|
||||
|
||||
**核心问题**:
|
||||
1. **架构层违规严重**:5 处 app 层直接访问 DB,破坏三层架构
|
||||
2. **权限校验不一致**:部分页面无校验,部分用 `auth()`,部分用 `getAuthContext()`
|
||||
3. **性能 waterfall 普遍**:9 处串行数据获取,应改为并行
|
||||
4. **DRY 违规突出**:`getParam` 函数在 16 个文件中重复
|
||||
5. **可访问性缺失**:焦点样式、aria-label、skip link 普遍缺失
|
||||
|
||||
建议按 P0 → P1 → P2 → P3 顺序修复,优先解决架构与安全问题。
|
||||
98
deletes/api/proctoring/event/route.ts
Normal file
98
deletes/api/proctoring/event/route.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// Moved from src/app/api/proctoring/event/route.ts
|
||||
// P0-6 fix: duplicate event reporting channel removed.
|
||||
// The canonical path is the Server Action `recordProctoringEventAction`
|
||||
// in src/modules/proctoring/actions.ts. This REST route was dead code
|
||||
// (no client referenced /api/proctoring/event) and duplicated the
|
||||
// Server Action's submission-ownership check + recordProctoringEvent call.
|
||||
|
||||
import { NextResponse } from "next/server"
|
||||
import { z } from "zod"
|
||||
import { requireAuth, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { db } from "@/shared/db"
|
||||
import { examSubmissions } from "@/shared/db/schema"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { recordProctoringEvent } from "@/modules/proctoring/data-access"
|
||||
import type { ProctoringEventType } from "@/modules/proctoring/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const EventSchema = z.object({
|
||||
submissionId: z.string().min(1),
|
||||
eventType: z.enum([
|
||||
"tab_switch",
|
||||
"window_blur",
|
||||
"copy_attempt",
|
||||
"paste_attempt",
|
||||
"right_click",
|
||||
"devtools_open",
|
||||
"fullscreen_exit",
|
||||
"idle_timeout",
|
||||
]) as z.ZodType<ProctoringEventType>,
|
||||
eventDetail: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const ctx = await requireAuth()
|
||||
|
||||
const body = await req.json().catch(() => null)
|
||||
if (!body) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Invalid JSON body" },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = EventSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: parsed.error.issues[0]?.message ?? "Invalid payload",
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// 安全校验:submission 必须属于当前学生
|
||||
const submission = await db.query.examSubmissions.findFirst({
|
||||
where: and(
|
||||
eq(examSubmissions.id, parsed.data.submissionId),
|
||||
eq(examSubmissions.studentId, ctx.userId),
|
||||
),
|
||||
columns: {
|
||||
id: true,
|
||||
examId: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!submission) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Submission not found for current user" },
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
|
||||
await recordProctoringEvent({
|
||||
submissionId: parsed.data.submissionId,
|
||||
studentId: ctx.userId,
|
||||
examId: submission.examId,
|
||||
eventType: parsed.data.eventType,
|
||||
eventDetail: parsed.data.eventDetail,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: error.message },
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
console.error("POST /api/proctoring/event error:", error)
|
||||
return NextResponse.json(
|
||||
{ success: false, message: "Failed to record proctoring event" },
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -49,28 +49,29 @@
|
||||
|
||||
---
|
||||
|
||||
### 2.2 classes 模块 — 🟡 需改进(文件拆分已修复,跨模块耦合仍存在)
|
||||
### 2.2 classes 模块 — 🟡 需改进(文件拆分已修复,跨模块耦合部分已修复)
|
||||
|
||||
**文件清单**:actions.ts (676 行) / data-access.ts (548 行) / data-access-stats.ts (531 行) / data-access-schedule.ts (194 行) / data-access-students.ts (244 行) / data-access-admin.ts (406 行) / types.ts (201 行)
|
||||
**文件清单**:actions.ts (676 行) / data-access.ts (548 行) / data-access-stats.ts (513 行) / data-access-schedule.ts (194 行) / data-access-students.ts (253 行) / data-access-admin.ts (406 行) / types.ts (201 行)
|
||||
|
||||
> ✅ `data-access.ts` 已于 2026-06-17 拆分为 5 个文件,所有文件均 ≤800 行,通过 re-export 保持向后兼容。
|
||||
> ✅ P0-7 已于 2026-06-18 修复:`data-access-stats.ts` 和 `data-access-students.ts` 不再直查 homework/exams 表,改为调用 `homework/data-access-classes.ts` 暴露的函数。
|
||||
|
||||
#### 2.2.1 职责混乱 — 混入三个外部业务领域(拆分后仍存在于子文件中)
|
||||
|
||||
`data-access-*.ts` 文件群仍承载了四个业务领域的逻辑(已按职责分文件,但跨域逻辑尚未迁移回所属模块):
|
||||
`data-access-*.ts` 文件群仍承载了四个业务领域的逻辑(已按职责分文件,homework 跨域查询已通过 data-access-classes 封装):
|
||||
|
||||
| 文件 | 逻辑 | 应属模块 |
|
||||
|------|------|---------|
|
||||
| data-access.ts | 教师身份解析、班级访问控制、班级 CRUD | classes(合理) |
|
||||
| data-access-students.ts | 班级学生查询 | classes(合理) |
|
||||
| data-access-stats.ts | `getClassHomeworkInsights` / `getGradeHomeworkInsights` 班级/年级作业洞察 | **homework** |
|
||||
| data-access-stats.ts | `getClassHomeworkInsights` / `getGradeHomeworkInsights` 班级/年级作业洞察 | classes(✅ P0-7 已修复:通过 `homework/data-access-classes` 获取数据) |
|
||||
| data-access-schedule.ts | 课表查询 `getClassSchedule`、课表项 CRUD | **scheduling** |
|
||||
| data-access-admin.ts | `getStudentsSubjectScores` 学生科目成绩 | **grades / homework** |
|
||||
| data-access-admin.ts | `getStudentsSubjectScores` 学生科目成绩 | classes(✅ P0-7 已修复:通过 `homework/data-access-classes` 获取数据) |
|
||||
|
||||
**关键问题**(P1-1 待修复):
|
||||
- `getClassHomeworkInsights` 和 `getGradeHomeworkInsights` 直接查询 `homeworkAssignments`、`homeworkSubmissions`、`homeworkAssignmentTargets`、`homeworkAssignmentQuestions`、`exams` 五张表,属于 homework 模块的核心业务,不应存在于 classes 模块。
|
||||
**关键问题**(P1-1 部分已修复):
|
||||
- ✅ P0-7 已修复:`getClassHomeworkInsights` 和 `getGradeHomeworkInsights` 不再直接查询 `homeworkAssignments`、`homeworkSubmissions`、`homeworkAssignmentTargets`、`homeworkAssignmentQuestions`、`exams` 表,改为调用 `homework/data-access-classes.ts` 暴露的函数(`getAssignmentIdsForStudents`/`getHomeworkAssignmentsWithSubject`/`getHomeworkAssignmentsByIds`/`getAssignmentMaxScoreById`/`getAssignmentTargetCounts`/`getHomeworkSubmissionsForStudents`)。
|
||||
- ✅ P0-7 已修复:`getStudentsSubjectScores` 不再直接关联 `homeworkSubmissions` + `exams` + `subjects`,改为调用 `homework/data-access-classes.ts` 暴露的函数(`getAssignmentIdsForStudents`/`getPublishedHomeworkAssignmentsWithSubject`/`getHomeworkSubmissionsForAssignments`)。
|
||||
- 课表 CRUD(`createClassScheduleItem` / `updateClassScheduleItem` / `deleteClassScheduleItem`)写入 `classSchedule` 表,P0-6 已统一 scheduling/data-access 为写入口,但 classes 侧的写函数仍存在(待后续迁移)。
|
||||
- `getStudentsSubjectScores` 直接关联 `homeworkSubmissions` + `exams` + `subjects` 计算学生科目分数,属于成绩分析逻辑。
|
||||
|
||||
#### 2.2.2 types.ts 跨领域类型污染
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getAnnouncements } from "@/modules/announcements/data-access"
|
||||
import { AnnouncementList } from "@/modules/announcements/components/announcement-list"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function AnnouncementsPage() {
|
||||
await requirePermission(Permissions.ANNOUNCEMENT_READ)
|
||||
const announcements = await getAnnouncements({ status: "published" })
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import { auth } from "@/auth"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -8,11 +7,10 @@ export default async function DashboardPage() {
|
||||
const session = await auth()
|
||||
if (!session?.user) redirect("/login")
|
||||
|
||||
const permissions = session.user.permissions ?? []
|
||||
const roles = session.user.roles ?? []
|
||||
|
||||
if (permissions.includes(Permissions.SCHOOL_MANAGE)) redirect("/admin/dashboard")
|
||||
if (permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)) redirect("/student/dashboard")
|
||||
if (roles.includes("admin")) redirect("/admin/dashboard")
|
||||
if (roles.includes("student")) redirect("/student/dashboard")
|
||||
if (roles.includes("parent")) redirect("/parent/dashboard")
|
||||
redirect("/teacher/dashboard")
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { auth } from "@/auth"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getGradeManagedClasses, getManagedGrades, getTeacherOptions } from "@/modules/classes/data-access"
|
||||
import { GradeClassesClient } from "@/modules/classes/components/grade-classes-view"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function GradeClassesPage() {
|
||||
const session = await auth()
|
||||
const userId = session?.user?.id ?? ""
|
||||
const ctx = await requirePermission(Permissions.GRADE_MANAGE)
|
||||
const userId = ctx.userId
|
||||
|
||||
const [classes, teachers, managedGrades] = await Promise.all([
|
||||
getGradeManagedClasses(userId),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
|
||||
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
|
||||
import { getGradesForStaff } from "@/modules/school/data-access"
|
||||
@@ -23,6 +24,7 @@ const getParam = (params: SearchParams, key: string) => {
|
||||
const fmt = (v: number | null, digits = 1) => (typeof v === "number" && Number.isFinite(v) ? v.toFixed(digits) : "-")
|
||||
|
||||
export default async function TeacherGradeInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
await requireAuth()
|
||||
const params = await searchParams
|
||||
const gradeId = getParam(params, "gradeId")
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Link from "next/link"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||
import { getStudentClasses, getStudentSchedule, getTeacherClasses, getTeacherTeachingSubjects } from "@/modules/classes/data-access"
|
||||
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
|
||||
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid"
|
||||
@@ -33,17 +33,16 @@ const formatDate = (date: Date | null) => {
|
||||
}
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const session = await auth()
|
||||
if (!session?.user) redirect("/login")
|
||||
const ctx = await requireAuth()
|
||||
|
||||
const userId = String(session.user.id ?? "").trim()
|
||||
const userId = ctx.userId
|
||||
const userProfile = await getUserProfile(userId)
|
||||
|
||||
if (!userProfile) {
|
||||
redirect("/login")
|
||||
}
|
||||
|
||||
const permissions = session.user.permissions ?? []
|
||||
const permissions = ctx.permissions
|
||||
const isStudent = permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)
|
||||
const isTeacher = permissions.includes(Permissions.EXAM_CREATE)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||
import { AdminSettingsView } from "@/modules/settings/components/admin-settings-view"
|
||||
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
|
||||
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
|
||||
@@ -11,15 +11,14 @@ import { Permissions } from "@/shared/types/permissions"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const session = await auth()
|
||||
if (!session?.user) redirect("/login")
|
||||
const ctx = await requireAuth()
|
||||
|
||||
const userId = String(session.user.id ?? "").trim()
|
||||
const userId = ctx.userId
|
||||
const userProfile = await getUserProfile(userId)
|
||||
|
||||
if (!userProfile) redirect("/login")
|
||||
|
||||
const permissions = session.user.permissions ?? []
|
||||
const permissions = ctx.permissions
|
||||
const notificationPrefs = await getNotificationPreferences(userId)
|
||||
|
||||
if (permissions.includes(Permissions.SETTINGS_ADMIN)) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import { Lock } from "lucide-react"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||
import { PasswordChangeForm } from "@/modules/settings/components/password-change-form"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
|
||||
@@ -12,8 +11,7 @@ export const metadata = {
|
||||
}
|
||||
|
||||
export default async function SecuritySettingsPage() {
|
||||
const session = await auth()
|
||||
if (!session?.user) redirect("/login")
|
||||
await requireAuth()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-8 p-8">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StudentDashboard } from "@/modules/dashboard/components/student-dashboard/student-dashboard-view"
|
||||
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
|
||||
import { getDemoStudentUser, getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
||||
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Inbox } from "lucide-react"
|
||||
|
||||
@@ -12,7 +13,7 @@ const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
||||
}
|
||||
|
||||
export default async function StudentDashboardPage() {
|
||||
const student = await getDemoStudentUser()
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
|
||||
@@ -1,31 +1,13 @@
|
||||
import { auth } from "@/auth"
|
||||
import { Inbox } from "lucide-react"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
|
||||
import { getAvailableCoursesForStudent, getStudentSelections } from "@/modules/elective/data-access-selections"
|
||||
import { StudentSelectionView } from "@/modules/elective/components/student-selection-view"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function StudentElectivePage() {
|
||||
const session = await auth()
|
||||
const studentId = String(session?.user?.id ?? "")
|
||||
|
||||
if (!studentId) {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Elective Courses</h2>
|
||||
<p className="text-muted-foreground">Browse and select elective courses.</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="Sign in required"
|
||||
description="Please sign in to view elective courses."
|
||||
icon={Inbox}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const ctx = await getAuthContext()
|
||||
const studentId = ctx.userId
|
||||
|
||||
const [availableCourses, mySelections] = await Promise.all([
|
||||
getAvailableCoursesForStudent(studentId),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { getDemoStudentUser, getStudentHomeworkTakeData } from "@/modules/homework/data-access"
|
||||
import { getStudentHomeworkTakeData } from "@/modules/homework/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
import { HomeworkTakeView } from "@/modules/homework/components/homework-take-view"
|
||||
import { HomeworkReviewView } from "@/modules/homework/components/student-homework-review-view"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
@@ -13,7 +14,7 @@ export default async function StudentAssignmentTakePage({
|
||||
params: Promise<{ assignmentId: string }>
|
||||
}) {
|
||||
const { assignmentId } = await params
|
||||
const student = await getDemoStudentUser()
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) return notFound()
|
||||
|
||||
const data = await getStudentHomeworkTakeData(assignmentId, student.id)
|
||||
|
||||
@@ -5,7 +5,8 @@ import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { getDemoStudentUser, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
||||
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
import { Inbox } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -39,7 +40,7 @@ const getActionVariant = (status: string): "default" | "secondary" | "outline" =
|
||||
const isAnswered = (status: string) => status === "submitted" || status === "graded"
|
||||
|
||||
export default async function StudentAssignmentsPage() {
|
||||
const student = await getDemoStudentUser()
|
||||
const student = await getCurrentStudentUser()
|
||||
|
||||
if (!student) {
|
||||
return (
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Inbox } from "lucide-react"
|
||||
|
||||
import { getStudentClasses } from "@/modules/classes/data-access"
|
||||
import { getDemoStudentUser } from "@/modules/homework/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
import { StudentCoursesView } from "@/modules/student/components/student-courses-view"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function StudentCoursesPage() {
|
||||
const student = await getDemoStudentUser()
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookI
|
||||
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getDemoStudentUser } from "@/modules/homework/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -15,7 +15,7 @@ export default async function StudentTextbookDetailPage({
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const student = await getDemoStudentUser()
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { BookOpen, Inbox } from "lucide-react"
|
||||
import { getTextbooks } from "@/modules/textbooks/data-access"
|
||||
import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
|
||||
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
|
||||
import { getDemoStudentUser } from "@/modules/homework/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -20,7 +20,7 @@ export default async function StudentTextbooksPage({
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const [student, sp] = await Promise.all([getDemoStudentUser(), searchParams])
|
||||
const [student, sp] = await Promise.all([getCurrentStudentUser(), searchParams])
|
||||
|
||||
if (!student) {
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Inbox } from "lucide-react"
|
||||
|
||||
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
|
||||
import { getDemoStudentUser } from "@/modules/homework/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
import { StudentScheduleFilters } from "@/modules/student/components/student-schedule-filters"
|
||||
import { StudentScheduleView } from "@/modules/student/components/student-schedule-view"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
@@ -15,7 +15,7 @@ export default async function StudentSchedulePage({
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const student = await getDemoStudentUser()
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
|
||||
import { requireAuth, requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
@@ -218,7 +218,7 @@ export async function getAnnouncementsAction(
|
||||
params?: GetAnnouncementsParams
|
||||
): Promise<ActionState<Announcement[]>> {
|
||||
try {
|
||||
await requireAuth()
|
||||
await requirePermission(Permissions.ANNOUNCEMENT_READ)
|
||||
const data = await getAnnouncements(params)
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
|
||||
@@ -8,8 +8,6 @@ import { announcements, users } from "@/shared/db/schema"
|
||||
import type {
|
||||
Announcement,
|
||||
AnnouncementInsertData,
|
||||
AnnouncementStatus,
|
||||
AnnouncementType,
|
||||
AnnouncementUpdateData,
|
||||
GetAnnouncementsParams,
|
||||
} from "./types"
|
||||
@@ -17,6 +15,8 @@ import type {
|
||||
const toIso = (d: Date | null | undefined): string | null =>
|
||||
d ? d.toISOString() : null
|
||||
|
||||
const toIsoRequired = (d: Date): string => d.toISOString()
|
||||
|
||||
const mapRow = (
|
||||
row: {
|
||||
id: string
|
||||
@@ -43,8 +43,8 @@ const mapRow = (
|
||||
authorId: row.authorId,
|
||||
authorName: row.authorName,
|
||||
publishedAt: toIso(row.publishedAt),
|
||||
createdAt: toIso(row.createdAt) as string,
|
||||
updatedAt: toIso(row.updatedAt) as string,
|
||||
createdAt: toIsoRequired(row.createdAt),
|
||||
updatedAt: toIsoRequired(row.updatedAt),
|
||||
})
|
||||
|
||||
export const getAnnouncements = cache(
|
||||
@@ -56,10 +56,10 @@ export const getAnnouncements = cache(
|
||||
|
||||
const conditions = []
|
||||
if (params?.status) {
|
||||
conditions.push(eq(announcements.status, params.status as AnnouncementStatus))
|
||||
conditions.push(eq(announcements.status, params.status))
|
||||
}
|
||||
if (params?.type) {
|
||||
conditions.push(eq(announcements.type, params.type as AnnouncementType))
|
||||
conditions.push(eq(announcements.type, params.type))
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
@@ -85,7 +85,8 @@ export const getAnnouncements = cache(
|
||||
.offset(offset)
|
||||
|
||||
return rows.map(mapRow)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getAnnouncements failed:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -115,7 +116,8 @@ export const getAnnouncementById = cache(
|
||||
.limit(1)
|
||||
|
||||
return row ? mapRow(row) : null
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getAnnouncementById failed:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,9 @@ const STATUS_OPTIONS: AttendanceStatus[] = [
|
||||
"excused",
|
||||
]
|
||||
|
||||
const isAttendanceStatus = (v: string): v is AttendanceStatus =>
|
||||
v === "present" || v === "absent" || v === "late" || v === "early_leave" || v === "excused"
|
||||
|
||||
function SubmitButton() {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
@@ -180,7 +183,11 @@ export function AttendanceSheet({
|
||||
<TableCell>
|
||||
<Select
|
||||
value={statuses[s.id] ?? "present"}
|
||||
onValueChange={(v) => handleStatusChange(s.id, v as AttendanceStatus)}
|
||||
onValueChange={(v) => {
|
||||
if (isAttendanceStatus(v)) {
|
||||
handleStatusChange(s.id, v)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
|
||||
@@ -192,7 +192,7 @@ export async function updateAttendanceRecord(
|
||||
id: string,
|
||||
data: UpdateAttendanceInput
|
||||
): Promise<void> {
|
||||
const update: Record<string, unknown> = { updatedAt: new Date() }
|
||||
const update: Partial<typeof attendanceRecords.$inferSelect> = { updatedAt: new Date() }
|
||||
if (data.status !== undefined) update.status = data.status
|
||||
if (data.remark !== undefined) update.remark = data.remark
|
||||
if (data.scheduleId !== undefined) update.scheduleId = data.scheduleId
|
||||
|
||||
@@ -15,17 +15,17 @@ import type {
|
||||
PaginatedResult,
|
||||
} from "./types"
|
||||
|
||||
const toIso = (d: Date) => d.toISOString()
|
||||
const toIso = (d: Date): string => d.toISOString()
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20
|
||||
const MAX_PAGE_SIZE = 100
|
||||
|
||||
const clampPageSize = (size?: number) => {
|
||||
const clampPageSize = (size?: number): number => {
|
||||
if (!size || size <= 0) return DEFAULT_PAGE_SIZE
|
||||
return Math.min(size, MAX_PAGE_SIZE)
|
||||
}
|
||||
|
||||
const clampPage = (page?: number) => {
|
||||
const clampPage = (page?: number): number => {
|
||||
if (!page || page <= 0) return 1
|
||||
return page
|
||||
}
|
||||
@@ -72,7 +72,7 @@ export async function getAuditLogs(
|
||||
detail: r.detail ?? null,
|
||||
ipAddress: r.ipAddress ?? null,
|
||||
userAgent: r.userAgent ?? null,
|
||||
status: r.status as "success" | "failure",
|
||||
status: r.status,
|
||||
createdAt: toIso(r.createdAt),
|
||||
})),
|
||||
total,
|
||||
@@ -80,7 +80,8 @@ export async function getAuditLogs(
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getAuditLogs failed:", error)
|
||||
return { items: [], total: 0, page, pageSize, totalPages: 0 }
|
||||
}
|
||||
}
|
||||
@@ -119,8 +120,8 @@ export async function getLoginLogs(
|
||||
id: r.id,
|
||||
userId: r.userId ?? null,
|
||||
userEmail: r.userEmail,
|
||||
action: r.action as "signin" | "signout" | "signup",
|
||||
status: r.status as "success" | "failure",
|
||||
action: r.action,
|
||||
status: r.status,
|
||||
ipAddress: r.ipAddress ?? null,
|
||||
userAgent: r.userAgent ?? null,
|
||||
errorMessage: r.errorMessage ?? null,
|
||||
@@ -131,7 +132,8 @@ export async function getLoginLogs(
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getLoginLogs failed:", error)
|
||||
return { items: [], total: 0, page, pageSize, totalPages: 0 }
|
||||
}
|
||||
}
|
||||
@@ -143,7 +145,8 @@ export async function getAuditModuleOptions(): Promise<string[]> {
|
||||
.from(auditLogs)
|
||||
.orderBy(asc(auditLogs.module))
|
||||
return rows.map((r) => r.module)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getAuditModuleOptions failed:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -183,7 +186,7 @@ export async function getDataChangeLogs(
|
||||
id: r.id,
|
||||
tableName: r.tableName,
|
||||
recordId: r.recordId,
|
||||
action: r.action as "create" | "update" | "delete",
|
||||
action: r.action,
|
||||
oldValue: r.oldValue ?? null,
|
||||
newValue: r.newValue ?? null,
|
||||
changedBy: r.changedBy,
|
||||
@@ -196,7 +199,8 @@ export async function getDataChangeLogs(
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getDataChangeLogs failed:", error)
|
||||
return { items: [], total: 0, page, pageSize, totalPages: 0 }
|
||||
}
|
||||
}
|
||||
@@ -212,7 +216,8 @@ export async function getDataChangeStats(): Promise<DataChangeStat[]> {
|
||||
.groupBy(dataChangeLogs.tableName)
|
||||
.orderBy(desc(count()))
|
||||
return rows.map((r) => ({ tableName: r.tableName, count: Number(r.count) }))
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getDataChangeStats failed:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -224,7 +229,8 @@ export async function getDataChangeTableOptions(): Promise<string[]> {
|
||||
.from(dataChangeLogs)
|
||||
.orderBy(asc(dataChangeLogs.tableName))
|
||||
return rows.map((r) => r.tableName)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getDataChangeTableOptions failed:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -235,8 +241,16 @@ export async function getDataChangeTableOptions(): Promise<string[]> {
|
||||
export async function getAuditLogsForExport(
|
||||
params?: AuditLogQueryParams
|
||||
): Promise<AuditLog[]> {
|
||||
const result = await getAuditLogs({ ...params, page: 1, pageSize: 100 })
|
||||
return result.items
|
||||
const items: AuditLog[] = []
|
||||
let page = 1
|
||||
let hasMore = true
|
||||
while (hasMore) {
|
||||
const result = await getAuditLogs({ ...params, page, pageSize: MAX_PAGE_SIZE })
|
||||
items.push(...result.items)
|
||||
hasMore = result.items.length === MAX_PAGE_SIZE
|
||||
page += 1
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,8 +259,16 @@ export async function getAuditLogsForExport(
|
||||
export async function getLoginLogsForExport(
|
||||
params?: LoginLogQueryParams
|
||||
): Promise<LoginLog[]> {
|
||||
const result = await getLoginLogs({ ...params, page: 1, pageSize: 100 })
|
||||
return result.items
|
||||
const items: LoginLog[] = []
|
||||
let page = 1
|
||||
let hasMore = true
|
||||
while (hasMore) {
|
||||
const result = await getLoginLogs({ ...params, page, pageSize: MAX_PAGE_SIZE })
|
||||
items.push(...result.items)
|
||||
hasMore = result.items.length === MAX_PAGE_SIZE
|
||||
page += 1
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -255,6 +277,14 @@ export async function getLoginLogsForExport(
|
||||
export async function getDataChangeLogsForExport(
|
||||
params?: DataChangeLogQueryParams
|
||||
): Promise<DataChangeLog[]> {
|
||||
const result = await getDataChangeLogs({ ...params, page: 1, pageSize: 100 })
|
||||
return result.items
|
||||
const items: DataChangeLog[] = []
|
||||
let page = 1
|
||||
let hasMore = true
|
||||
while (hasMore) {
|
||||
const result = await getDataChangeLogs({ ...params, page, pageSize: MAX_PAGE_SIZE })
|
||||
items.push(...result.items)
|
||||
hasMore = result.items.length === MAX_PAGE_SIZE
|
||||
page += 1
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { and, eq, sql, or } from "drizzle-orm"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { grades, classes } from "@/shared/db/schema"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import {
|
||||
createAdminClass,
|
||||
createClassScheduleItem,
|
||||
createTeacherClass,
|
||||
deleteAdminClass,
|
||||
deleteClassScheduleItem,
|
||||
deleteTeacherClass,
|
||||
enrollStudentByEmail,
|
||||
enrollStudentByInvitationCode,
|
||||
@@ -23,10 +18,31 @@ import {
|
||||
setClassSubjectTeachers,
|
||||
setStudentEnrollmentStatus,
|
||||
updateAdminClass,
|
||||
updateClassScheduleItem,
|
||||
updateTeacherClass,
|
||||
getClassGradeId,
|
||||
} from "./data-access"
|
||||
import { findGradeIdByHeadAndName, isGradeHead, isGradeManager } from "@/modules/school/data-access"
|
||||
import {
|
||||
createClassScheduleItem,
|
||||
updateClassScheduleItem,
|
||||
deleteClassScheduleItem,
|
||||
} from "@/modules/scheduling/data-access-class-schedule"
|
||||
import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "./types"
|
||||
import {
|
||||
CreateTeacherClassSchema,
|
||||
UpdateTeacherClassSchema,
|
||||
DeleteTeacherClassSchema,
|
||||
CreateAdminClassSchema,
|
||||
UpdateAdminClassSchema,
|
||||
DeleteAdminClassSchema,
|
||||
CreateGradeClassSchema,
|
||||
UpdateGradeClassSchema,
|
||||
DeleteGradeClassSchema,
|
||||
CreateClassScheduleItemSchema,
|
||||
UpdateClassScheduleItemSchema,
|
||||
DeleteClassScheduleItemSchema,
|
||||
EnrollStudentByEmailSchema,
|
||||
} from "./schema"
|
||||
|
||||
const isClassSubject = (v: string): v is ClassSubject => DEFAULT_CLASS_SUBJECTS.includes(v as ClassSubject)
|
||||
|
||||
@@ -37,45 +53,42 @@ export async function createTeacherClassAction(
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_CREATE)
|
||||
|
||||
const schoolName = formData.get("schoolName")
|
||||
const schoolId = formData.get("schoolId")
|
||||
const name = formData.get("name")
|
||||
const grade = formData.get("grade")
|
||||
const gradeId = formData.get("gradeId")
|
||||
const homeroom = formData.get("homeroom")
|
||||
const room = formData.get("room")
|
||||
const parsed = CreateTeacherClassSchema.safeParse({
|
||||
name: formData.get("name"),
|
||||
grade: formData.get("grade"),
|
||||
schoolName: formData.get("schoolName"),
|
||||
schoolId: formData.get("schoolId"),
|
||||
gradeId: formData.get("gradeId"),
|
||||
homeroom: formData.get("homeroom"),
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Class name and grade are required" }
|
||||
}
|
||||
|
||||
if (typeof name !== "string" || name.trim().length === 0) {
|
||||
return { success: false, message: "Class name is required" }
|
||||
}
|
||||
if (typeof grade !== "string" || grade.trim().length === 0) {
|
||||
return { success: false, message: "Grade is required" }
|
||||
}
|
||||
const { name, grade, schoolName, schoolId, gradeId, homeroom, room } = parsed.data
|
||||
|
||||
if (!ctx.roles.includes("admin")) {
|
||||
const userId = ctx.userId
|
||||
|
||||
const normalizedGradeId = typeof gradeId === "string" ? gradeId.trim() : ""
|
||||
const normalizedGradeName = grade.trim().toLowerCase()
|
||||
const where = normalizedGradeId
|
||||
? and(eq(grades.id, normalizedGradeId), eq(grades.gradeHeadId, userId))
|
||||
: and(eq(grades.gradeHeadId, userId), sql`LOWER(${grades.name}) = ${normalizedGradeName}`)
|
||||
|
||||
const [ownedGrade] = await db.select({ id: grades.id }).from(grades).where(where).limit(1)
|
||||
if (!ownedGrade) {
|
||||
const isOwner = normalizedGradeId
|
||||
? await isGradeHead(normalizedGradeId, userId)
|
||||
: Boolean(await findGradeIdByHeadAndName(userId, grade))
|
||||
if (!isOwner) {
|
||||
return { success: false, message: "Only admins and grade heads can create classes" }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const id = await createTeacherClass({
|
||||
schoolName: typeof schoolName === "string" ? schoolName : null,
|
||||
schoolId: typeof schoolId === "string" ? schoolId : null,
|
||||
schoolName: schoolName ?? null,
|
||||
schoolId: schoolId ?? null,
|
||||
name,
|
||||
grade,
|
||||
gradeId: typeof gradeId === "string" ? gradeId : null,
|
||||
homeroom: typeof homeroom === "string" ? homeroom : null,
|
||||
room: typeof room === "string" ? room : null,
|
||||
gradeId: gradeId ?? null,
|
||||
homeroom: homeroom ?? null,
|
||||
room: room ?? null,
|
||||
})
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
@@ -98,27 +111,31 @@ export async function updateTeacherClassAction(
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_UPDATE)
|
||||
|
||||
const schoolName = formData.get("schoolName")
|
||||
const schoolId = formData.get("schoolId")
|
||||
const name = formData.get("name")
|
||||
const grade = formData.get("grade")
|
||||
const gradeId = formData.get("gradeId")
|
||||
const homeroom = formData.get("homeroom")
|
||||
const room = formData.get("room")
|
||||
|
||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||
const parsed = UpdateTeacherClassSchema.safeParse({
|
||||
classId,
|
||||
schoolName: formData.get("schoolName"),
|
||||
schoolId: formData.get("schoolId"),
|
||||
name: formData.get("name"),
|
||||
grade: formData.get("grade"),
|
||||
gradeId: formData.get("gradeId"),
|
||||
homeroom: formData.get("homeroom"),
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, homeroom, room } = parsed.data
|
||||
|
||||
try {
|
||||
await updateTeacherClass(classId, {
|
||||
schoolName: typeof schoolName === "string" ? schoolName : undefined,
|
||||
schoolId: typeof schoolId === "string" ? schoolId : undefined,
|
||||
name: typeof name === "string" ? name : undefined,
|
||||
grade: typeof grade === "string" ? grade : undefined,
|
||||
gradeId: typeof gradeId === "string" ? gradeId : undefined,
|
||||
homeroom: typeof homeroom === "string" ? homeroom : undefined,
|
||||
room: typeof room === "string" ? room : undefined,
|
||||
await updateTeacherClass(validatedClassId, {
|
||||
schoolName: schoolName ?? undefined,
|
||||
schoolId: schoolId ?? undefined,
|
||||
name: name ?? undefined,
|
||||
grade: grade ?? undefined,
|
||||
gradeId: gradeId ?? undefined,
|
||||
homeroom: homeroom ?? undefined,
|
||||
room: room ?? undefined,
|
||||
})
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
@@ -137,12 +154,13 @@ export async function deleteTeacherClassAction(classId: string): Promise<ActionS
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_DELETE)
|
||||
|
||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||
const parsed = DeleteTeacherClassSchema.safeParse({ classId })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteTeacherClass(classId)
|
||||
await deleteTeacherClass(parsed.data.classId)
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
@@ -163,46 +181,38 @@ export async function createGradeClassAction(
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_CREATE)
|
||||
|
||||
const schoolName = formData.get("schoolName")
|
||||
const schoolId = formData.get("schoolId")
|
||||
const name = formData.get("name")
|
||||
const grade = formData.get("grade")
|
||||
const gradeId = formData.get("gradeId")
|
||||
const teacherId = formData.get("teacherId")
|
||||
const homeroom = formData.get("homeroom")
|
||||
const room = formData.get("room")
|
||||
const parsed = CreateGradeClassSchema.safeParse({
|
||||
name: formData.get("name"),
|
||||
gradeId: formData.get("gradeId"),
|
||||
teacherId: formData.get("teacherId"),
|
||||
schoolName: formData.get("schoolName"),
|
||||
schoolId: formData.get("schoolId"),
|
||||
grade: formData.get("grade"),
|
||||
homeroom: formData.get("homeroom"),
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Class name, grade and teacher are required" }
|
||||
}
|
||||
|
||||
if (typeof name !== "string" || name.trim().length === 0) {
|
||||
return { success: false, message: "Class name is required" }
|
||||
}
|
||||
if (typeof gradeId !== "string" || gradeId.trim().length === 0) {
|
||||
return { success: false, message: "Grade selection is required" }
|
||||
}
|
||||
if (typeof teacherId !== "string" || teacherId.trim().length === 0) {
|
||||
return { success: false, message: "Teacher is required" }
|
||||
}
|
||||
const { name, gradeId, teacherId, schoolName, schoolId, grade, homeroom, room } = parsed.data
|
||||
|
||||
// Verify access
|
||||
const [managedGrade] = await db
|
||||
.select({ id: grades.id })
|
||||
.from(grades)
|
||||
.where(and(eq(grades.id, gradeId), or(eq(grades.gradeHeadId, ctx.userId), eq(grades.teachingHeadId, ctx.userId))))
|
||||
.limit(1)
|
||||
|
||||
if (!managedGrade) {
|
||||
const isManager = await isGradeManager(gradeId, ctx.userId)
|
||||
if (!isManager) {
|
||||
return { success: false, message: "You do not have permission to create classes for this grade" }
|
||||
}
|
||||
|
||||
try {
|
||||
const id = await createAdminClass({
|
||||
schoolName: typeof schoolName === "string" ? schoolName : null,
|
||||
schoolId: typeof schoolId === "string" ? schoolId : null,
|
||||
schoolName: schoolName ?? null,
|
||||
schoolId: schoolId ?? null,
|
||||
name,
|
||||
grade: typeof grade === "string" ? grade : "", // Should be passed from UI based on selected grade
|
||||
grade: grade ?? "", // Should be passed from UI based on selected grade
|
||||
gradeId,
|
||||
teacherId,
|
||||
homeroom: typeof homeroom === "string" ? homeroom : null,
|
||||
room: typeof room === "string" ? room : null,
|
||||
homeroom: homeroom ?? null,
|
||||
room: room ?? null,
|
||||
})
|
||||
revalidatePath("/management/grade/classes")
|
||||
return { success: true, message: "Class created successfully", data: id }
|
||||
@@ -223,73 +233,62 @@ export async function updateGradeClassAction(
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_UPDATE)
|
||||
|
||||
const schoolName = formData.get("schoolName")
|
||||
const schoolId = formData.get("schoolId")
|
||||
const name = formData.get("name")
|
||||
const grade = formData.get("grade")
|
||||
const gradeId = formData.get("gradeId")
|
||||
const teacherId = formData.get("teacherId")
|
||||
const homeroom = formData.get("homeroom")
|
||||
const room = formData.get("room")
|
||||
const subjectTeachers = formData.get("subjectTeachers")
|
||||
|
||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||
const parsed = UpdateGradeClassSchema.safeParse({
|
||||
classId,
|
||||
schoolName: formData.get("schoolName"),
|
||||
schoolId: formData.get("schoolId"),
|
||||
name: formData.get("name"),
|
||||
grade: formData.get("grade"),
|
||||
gradeId: formData.get("gradeId"),
|
||||
teacherId: formData.get("teacherId"),
|
||||
homeroom: formData.get("homeroom"),
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
// Verify access: Check if the class belongs to a managed grade
|
||||
const [cls] = await db
|
||||
.select({ gradeId: classes.gradeId })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, classId))
|
||||
.limit(1)
|
||||
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, teacherId, homeroom, room } = parsed.data
|
||||
const subjectTeachers = formData.get("subjectTeachers")
|
||||
|
||||
if (!cls || !cls.gradeId) {
|
||||
// Verify access: Check if the class belongs to a managed grade
|
||||
const classGradeId = await getClassGradeId(validatedClassId)
|
||||
if (!classGradeId) {
|
||||
return { success: false, message: "Class not found or not linked to a grade" }
|
||||
}
|
||||
|
||||
const [managedGrade] = await db
|
||||
.select({ id: grades.id })
|
||||
.from(grades)
|
||||
.where(and(eq(grades.id, cls.gradeId), or(eq(grades.gradeHeadId, ctx.userId), eq(grades.teachingHeadId, ctx.userId))))
|
||||
.limit(1)
|
||||
|
||||
if (!managedGrade) {
|
||||
const isManager = await isGradeManager(classGradeId, ctx.userId)
|
||||
if (!isManager) {
|
||||
return { success: false, message: "You do not have permission to update this class" }
|
||||
}
|
||||
|
||||
// If changing gradeId, verify target grade too
|
||||
if (typeof gradeId === "string" && gradeId !== cls.gradeId) {
|
||||
const [targetGrade] = await db
|
||||
.select({ id: grades.id })
|
||||
.from(grades)
|
||||
.where(and(eq(grades.id, gradeId), or(eq(grades.gradeHeadId, ctx.userId), eq(grades.teachingHeadId, ctx.userId))))
|
||||
.limit(1)
|
||||
|
||||
if (!targetGrade) {
|
||||
return { success: false, message: "You do not have permission to move class to this grade" }
|
||||
}
|
||||
if (typeof gradeId === "string" && gradeId !== classGradeId) {
|
||||
const isTargetManager = await isGradeManager(gradeId, ctx.userId)
|
||||
if (!isTargetManager) {
|
||||
return { success: false, message: "You do not have permission to move class to this grade" }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await updateAdminClass(classId, {
|
||||
schoolName: typeof schoolName === "string" ? schoolName : undefined,
|
||||
schoolId: typeof schoolId === "string" ? schoolId : undefined,
|
||||
name: typeof name === "string" ? name : undefined,
|
||||
grade: typeof grade === "string" ? grade : undefined,
|
||||
gradeId: typeof gradeId === "string" ? gradeId : undefined,
|
||||
teacherId: typeof teacherId === "string" ? teacherId : undefined,
|
||||
homeroom: typeof homeroom === "string" ? homeroom : undefined,
|
||||
room: typeof room === "string" ? room : undefined,
|
||||
await updateAdminClass(validatedClassId, {
|
||||
schoolName: schoolName ?? undefined,
|
||||
schoolId: schoolId ?? undefined,
|
||||
name: name ?? undefined,
|
||||
grade: grade ?? undefined,
|
||||
gradeId: gradeId ?? undefined,
|
||||
teacherId: teacherId ?? undefined,
|
||||
homeroom: homeroom ?? undefined,
|
||||
room: room ?? undefined,
|
||||
})
|
||||
|
||||
if (typeof subjectTeachers === "string" && subjectTeachers.trim().length > 0) {
|
||||
const parsed = JSON.parse(subjectTeachers) as unknown
|
||||
if (!Array.isArray(parsed)) throw new Error("Invalid subject teachers")
|
||||
const parsedTeachers = JSON.parse(subjectTeachers) as unknown
|
||||
if (!Array.isArray(parsedTeachers)) throw new Error("Invalid subject teachers")
|
||||
|
||||
await setClassSubjectTeachers({
|
||||
classId,
|
||||
assignments: parsed.flatMap((item) => {
|
||||
classId: validatedClassId,
|
||||
assignments: parsedTeachers.flatMap((item) => {
|
||||
if (!item || typeof item !== "object") return []
|
||||
const subject = (item as { subject?: unknown }).subject
|
||||
const teacherId = (item as { teacherId?: unknown }).teacherId
|
||||
@@ -322,33 +321,26 @@ export async function deleteGradeClassAction(classId: string): Promise<ActionSta
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.CLASS_DELETE)
|
||||
|
||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||
const parsed = DeleteGradeClassSchema.safeParse({ classId })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
// Verify access
|
||||
const [cls] = await db
|
||||
.select({ gradeId: classes.gradeId })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, classId))
|
||||
.limit(1)
|
||||
const { classId: validatedClassId } = parsed.data
|
||||
|
||||
if (!cls || !cls.gradeId) {
|
||||
// Verify access
|
||||
const classGradeId = await getClassGradeId(validatedClassId)
|
||||
if (!classGradeId) {
|
||||
return { success: false, message: "Class not found or not linked to a grade" }
|
||||
}
|
||||
|
||||
const [managedGrade] = await db
|
||||
.select({ id: grades.id })
|
||||
.from(grades)
|
||||
.where(and(eq(grades.id, cls.gradeId), or(eq(grades.gradeHeadId, ctx.userId), eq(grades.teachingHeadId, ctx.userId))))
|
||||
.limit(1)
|
||||
|
||||
if (!managedGrade) {
|
||||
const isManager = await isGradeManager(classGradeId, ctx.userId)
|
||||
if (!isManager) {
|
||||
return { success: false, message: "You do not have permission to delete this class" }
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAdminClass(classId)
|
||||
await deleteAdminClass(validatedClassId)
|
||||
revalidatePath("/management/grade/classes")
|
||||
return { success: true, message: "Class deleted successfully" }
|
||||
} catch (error) {
|
||||
@@ -368,16 +360,16 @@ export async function enrollStudentByEmailAction(
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_ENROLL)
|
||||
|
||||
const email = formData.get("email")
|
||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||
return { success: false, message: "Please select a class" }
|
||||
}
|
||||
if (typeof email !== "string" || email.trim().length === 0) {
|
||||
return { success: false, message: "Student email is required" }
|
||||
const parsed = EnrollStudentByEmailSchema.safeParse({
|
||||
classId,
|
||||
email: formData.get("email"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Please select a class and provide student email" }
|
||||
}
|
||||
|
||||
try {
|
||||
await enrollStudentByEmail(classId, email)
|
||||
await enrollStudentByEmail(parsed.data.classId, parsed.data.email)
|
||||
revalidatePath("/teacher/classes/students")
|
||||
revalidatePath("/teacher/classes/my")
|
||||
return { success: true, message: "Student added successfully" }
|
||||
@@ -508,38 +500,29 @@ export async function createClassScheduleItemAction(
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_SCHEDULE)
|
||||
|
||||
const classId = formData.get("classId")
|
||||
const weekday = formData.get("weekday")
|
||||
const startTime = formData.get("startTime")
|
||||
const endTime = formData.get("endTime")
|
||||
const course = formData.get("course")
|
||||
const location = formData.get("location")
|
||||
const parsed = CreateClassScheduleItemSchema.safeParse({
|
||||
classId: formData.get("classId"),
|
||||
weekday: formData.get("weekday"),
|
||||
course: formData.get("course"),
|
||||
startTime: formData.get("startTime"),
|
||||
endTime: formData.get("endTime"),
|
||||
location: formData.get("location"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Invalid schedule item data" }
|
||||
}
|
||||
|
||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||
return { success: false, message: "Please select a class" }
|
||||
}
|
||||
if (typeof weekday !== "string" || weekday.trim().length === 0) {
|
||||
return { success: false, message: "Weekday is required" }
|
||||
}
|
||||
const weekdayNum = Number(weekday)
|
||||
if (!Number.isInteger(weekdayNum) || weekdayNum < 1 || weekdayNum > 7) {
|
||||
return { success: false, message: "Invalid weekday" }
|
||||
}
|
||||
if (typeof course !== "string" || course.trim().length === 0) {
|
||||
return { success: false, message: "Course is required" }
|
||||
}
|
||||
if (typeof startTime !== "string" || typeof endTime !== "string") {
|
||||
return { success: false, message: "Time is required" }
|
||||
}
|
||||
const { classId, weekday, course, startTime, endTime, location } = parsed.data
|
||||
|
||||
try {
|
||||
const id = await createClassScheduleItem({
|
||||
classId,
|
||||
weekday: weekdayNum as 1 | 2 | 3 | 4 | 5 | 6 | 7,
|
||||
// weekday 已被 Zod 校验为 1-7 的整数,断言为 Weekday 联合类型
|
||||
weekday: weekday as 1 | 2 | 3 | 4 | 5 | 6 | 7,
|
||||
startTime,
|
||||
endTime,
|
||||
course,
|
||||
location: typeof location === "string" ? location : null,
|
||||
location: location ?? null,
|
||||
})
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Schedule item created successfully", data: id }
|
||||
@@ -560,30 +543,30 @@ export async function updateClassScheduleItemAction(
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_SCHEDULE)
|
||||
|
||||
const classId = formData.get("classId")
|
||||
const weekday = formData.get("weekday")
|
||||
const startTime = formData.get("startTime")
|
||||
const endTime = formData.get("endTime")
|
||||
const course = formData.get("course")
|
||||
const location = formData.get("location")
|
||||
|
||||
if (typeof scheduleId !== "string" || scheduleId.trim().length === 0) {
|
||||
return { success: false, message: "Missing schedule id" }
|
||||
const parsed = UpdateClassScheduleItemSchema.safeParse({
|
||||
scheduleId,
|
||||
classId: formData.get("classId"),
|
||||
weekday: formData.get("weekday") || undefined,
|
||||
course: formData.get("course"),
|
||||
startTime: formData.get("startTime"),
|
||||
endTime: formData.get("endTime"),
|
||||
location: formData.get("location"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing or invalid schedule id" }
|
||||
}
|
||||
|
||||
const weekdayNum = typeof weekday === "string" && weekday.trim().length > 0 ? Number(weekday) : undefined
|
||||
if (weekdayNum !== undefined && (!Number.isInteger(weekdayNum) || weekdayNum < 1 || weekdayNum > 7)) {
|
||||
return { success: false, message: "Invalid weekday" }
|
||||
}
|
||||
const { scheduleId: validatedScheduleId, classId, weekday, course, startTime, endTime, location } = parsed.data
|
||||
|
||||
try {
|
||||
await updateClassScheduleItem(scheduleId, {
|
||||
classId: typeof classId === "string" ? classId : undefined,
|
||||
weekday: weekdayNum as 1 | 2 | 3 | 4 | 5 | 6 | 7 | undefined,
|
||||
startTime: typeof startTime === "string" ? startTime : undefined,
|
||||
endTime: typeof endTime === "string" ? endTime : undefined,
|
||||
course: typeof course === "string" ? course : undefined,
|
||||
location: typeof location === "string" ? location : undefined,
|
||||
await updateClassScheduleItem(validatedScheduleId, {
|
||||
classId: classId ?? undefined,
|
||||
// weekday 已被 Zod 校验为 1-7 的整数或 null/undefined,断言为 Weekday 联合类型
|
||||
weekday: (weekday ?? undefined) as 1 | 2 | 3 | 4 | 5 | 6 | 7 | undefined,
|
||||
startTime: startTime ?? undefined,
|
||||
endTime: endTime ?? undefined,
|
||||
course: course ?? undefined,
|
||||
location: location ?? undefined,
|
||||
})
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Schedule item updated successfully" }
|
||||
@@ -600,12 +583,13 @@ export async function deleteClassScheduleItemAction(scheduleId: string): Promise
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_SCHEDULE)
|
||||
|
||||
if (typeof scheduleId !== "string" || scheduleId.trim().length === 0) {
|
||||
const parsed = DeleteClassScheduleItemSchema.safeParse({ scheduleId })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing schedule id" }
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteClassScheduleItem(scheduleId)
|
||||
await deleteClassScheduleItem(parsed.data.scheduleId)
|
||||
revalidatePath("/teacher/classes/schedule")
|
||||
return { success: true, message: "Schedule item deleted successfully" }
|
||||
} catch (error) {
|
||||
@@ -624,35 +608,32 @@ export async function createAdminClassAction(
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_CREATE)
|
||||
|
||||
const schoolName = formData.get("schoolName")
|
||||
const schoolId = formData.get("schoolId")
|
||||
const name = formData.get("name")
|
||||
const grade = formData.get("grade")
|
||||
const gradeId = formData.get("gradeId")
|
||||
const teacherId = formData.get("teacherId")
|
||||
const homeroom = formData.get("homeroom")
|
||||
const room = formData.get("room")
|
||||
const parsed = CreateAdminClassSchema.safeParse({
|
||||
name: formData.get("name"),
|
||||
grade: formData.get("grade"),
|
||||
teacherId: formData.get("teacherId"),
|
||||
schoolName: formData.get("schoolName"),
|
||||
schoolId: formData.get("schoolId"),
|
||||
gradeId: formData.get("gradeId"),
|
||||
homeroom: formData.get("homeroom"),
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Class name, grade and teacher are required" }
|
||||
}
|
||||
|
||||
if (typeof name !== "string" || name.trim().length === 0) {
|
||||
return { success: false, message: "Class name is required" }
|
||||
}
|
||||
if (typeof grade !== "string" || grade.trim().length === 0) {
|
||||
return { success: false, message: "Grade is required" }
|
||||
}
|
||||
if (typeof teacherId !== "string" || teacherId.trim().length === 0) {
|
||||
return { success: false, message: "Teacher is required" }
|
||||
}
|
||||
const { name, grade, teacherId, schoolName, schoolId, gradeId, homeroom, room } = parsed.data
|
||||
|
||||
try {
|
||||
const id = await createAdminClass({
|
||||
schoolName: typeof schoolName === "string" ? schoolName : null,
|
||||
schoolId: typeof schoolId === "string" ? schoolId : null,
|
||||
schoolName: schoolName ?? null,
|
||||
schoolId: schoolId ?? null,
|
||||
name,
|
||||
grade,
|
||||
gradeId: typeof gradeId === "string" ? gradeId : null,
|
||||
gradeId: gradeId ?? null,
|
||||
teacherId,
|
||||
homeroom: typeof homeroom === "string" ? homeroom : null,
|
||||
room: typeof room === "string" ? room : null,
|
||||
homeroom: homeroom ?? null,
|
||||
room: room ?? null,
|
||||
})
|
||||
revalidatePath("/admin/school/classes")
|
||||
revalidatePath("/teacher/classes/my")
|
||||
@@ -676,39 +657,43 @@ export async function updateAdminClassAction(
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_UPDATE)
|
||||
|
||||
const schoolName = formData.get("schoolName")
|
||||
const schoolId = formData.get("schoolId")
|
||||
const name = formData.get("name")
|
||||
const grade = formData.get("grade")
|
||||
const gradeId = formData.get("gradeId")
|
||||
const teacherId = formData.get("teacherId")
|
||||
const homeroom = formData.get("homeroom")
|
||||
const room = formData.get("room")
|
||||
const subjectTeachers = formData.get("subjectTeachers")
|
||||
|
||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||
const parsed = UpdateAdminClassSchema.safeParse({
|
||||
classId,
|
||||
schoolName: formData.get("schoolName"),
|
||||
schoolId: formData.get("schoolId"),
|
||||
name: formData.get("name"),
|
||||
grade: formData.get("grade"),
|
||||
gradeId: formData.get("gradeId"),
|
||||
teacherId: formData.get("teacherId"),
|
||||
homeroom: formData.get("homeroom"),
|
||||
room: formData.get("room"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, teacherId, homeroom, room } = parsed.data
|
||||
const subjectTeachers = formData.get("subjectTeachers")
|
||||
|
||||
try {
|
||||
await updateAdminClass(classId, {
|
||||
schoolName: typeof schoolName === "string" ? schoolName : undefined,
|
||||
schoolId: typeof schoolId === "string" ? schoolId : undefined,
|
||||
name: typeof name === "string" ? name : undefined,
|
||||
grade: typeof grade === "string" ? grade : undefined,
|
||||
gradeId: typeof gradeId === "string" ? gradeId : undefined,
|
||||
teacherId: typeof teacherId === "string" ? teacherId : undefined,
|
||||
homeroom: typeof homeroom === "string" ? homeroom : undefined,
|
||||
room: typeof room === "string" ? room : undefined,
|
||||
await updateAdminClass(validatedClassId, {
|
||||
schoolName: schoolName ?? undefined,
|
||||
schoolId: schoolId ?? undefined,
|
||||
name: name ?? undefined,
|
||||
grade: grade ?? undefined,
|
||||
gradeId: gradeId ?? undefined,
|
||||
teacherId: teacherId ?? undefined,
|
||||
homeroom: homeroom ?? undefined,
|
||||
room: room ?? undefined,
|
||||
})
|
||||
|
||||
if (typeof subjectTeachers === "string" && subjectTeachers.trim().length > 0) {
|
||||
const parsed = JSON.parse(subjectTeachers) as unknown
|
||||
if (!Array.isArray(parsed)) throw new Error("Invalid subject teachers")
|
||||
const parsedTeachers = JSON.parse(subjectTeachers) as unknown
|
||||
if (!Array.isArray(parsedTeachers)) throw new Error("Invalid subject teachers")
|
||||
|
||||
await setClassSubjectTeachers({
|
||||
classId,
|
||||
assignments: parsed.flatMap((item) => {
|
||||
classId: validatedClassId,
|
||||
assignments: parsedTeachers.flatMap((item) => {
|
||||
if (!item || typeof item !== "object") return []
|
||||
const subject = (item as { subject?: unknown }).subject
|
||||
const teacherId = (item as { teacherId?: unknown }).teacherId
|
||||
@@ -744,12 +729,13 @@ export async function deleteAdminClassAction(classId: string): Promise<ActionSta
|
||||
try {
|
||||
await requirePermission(Permissions.CLASS_DELETE)
|
||||
|
||||
if (typeof classId !== "string" || classId.trim().length === 0) {
|
||||
const parsed = DeleteAdminClassSchema.safeParse({ classId })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing class id" }
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAdminClass(classId)
|
||||
await deleteAdminClass(parsed.data.classId)
|
||||
revalidatePath("/admin/school/classes")
|
||||
revalidatePath("/teacher/classes/my")
|
||||
revalidatePath("/teacher/classes/students")
|
||||
|
||||
@@ -31,6 +31,12 @@ import {
|
||||
isDuplicateInvitationCodeError,
|
||||
} from "./data-access"
|
||||
|
||||
const isClassSubject = (v: unknown): v is ClassSubject =>
|
||||
typeof v === "string" && (DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(v)
|
||||
|
||||
const toClassSubject = (v: string): ClassSubject | null =>
|
||||
isClassSubject(v) ? v : null
|
||||
|
||||
export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> => {
|
||||
const [rows, subjectRows] = await Promise.all([
|
||||
(async () => {
|
||||
@@ -79,7 +85,8 @@ export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> =>
|
||||
asc(classes.homeroom),
|
||||
asc(classes.room)
|
||||
)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getAdminClasses primary query failed, falling back:", error)
|
||||
return await db
|
||||
.select({
|
||||
id: classes.id,
|
||||
@@ -132,8 +139,8 @@ export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> =>
|
||||
|
||||
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
|
||||
for (const r of subjectRows) {
|
||||
const subject = r.subject as ClassSubject
|
||||
if (!DEFAULT_CLASS_SUBJECTS.includes(subject)) continue
|
||||
const subject = toClassSubject(r.subject)
|
||||
if (!subject) continue
|
||||
const teacher =
|
||||
typeof r.teacherId === "string" && r.teacherId.length > 0
|
||||
? { id: r.teacherId, name: r.teacherName ?? "Unnamed", email: r.teacherEmail ?? "" }
|
||||
@@ -234,7 +241,8 @@ export const getGradeManagedClasses = cache(async (userId: string): Promise<Admi
|
||||
asc(classes.homeroom),
|
||||
asc(classes.room)
|
||||
)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getGradeManagedClasses primary query failed:", error)
|
||||
return []
|
||||
}
|
||||
})(),
|
||||
@@ -256,8 +264,8 @@ export const getGradeManagedClasses = cache(async (userId: string): Promise<Admi
|
||||
|
||||
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
|
||||
for (const r of subjectRows) {
|
||||
const subject = r.subject as ClassSubject
|
||||
if (!DEFAULT_CLASS_SUBJECTS.includes(subject)) continue
|
||||
const subject = toClassSubject(r.subject)
|
||||
if (!subject) continue
|
||||
const teacher =
|
||||
typeof r.teacherId === "string" && r.teacherId.length > 0
|
||||
? { id: r.teacherId, name: r.teacherName ?? "Unnamed", email: r.teacherEmail ?? "" }
|
||||
@@ -300,7 +308,7 @@ export const getGradeManagedClasses = cache(async (userId: string): Promise<Admi
|
||||
return list
|
||||
})
|
||||
|
||||
export const getManagedGrades = cache(async (userId: string) => {
|
||||
export const getManagedGrades = cache(async (userId: string): Promise<{ id: string; name: string; schoolId: string; schoolName: string }[]> => {
|
||||
return await db
|
||||
.select({
|
||||
id: grades.id,
|
||||
@@ -346,7 +354,11 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
|
||||
.select({ id: subjects.id, name: subjects.name })
|
||||
.from(subjects)
|
||||
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
|
||||
const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
|
||||
const idByName = new Map<ClassSubject, string>()
|
||||
for (const r of subjectRows) {
|
||||
const subject = toClassSubject(r.name)
|
||||
if (subject) idByName.set(subject, r.id)
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(classes).values({
|
||||
@@ -362,13 +374,11 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
|
||||
teacherId,
|
||||
})
|
||||
|
||||
const values = DEFAULT_CLASS_SUBJECTS
|
||||
.filter((name) => idByName.has(name))
|
||||
.map((name) => ({
|
||||
classId: id,
|
||||
subjectId: idByName.get(name)!,
|
||||
teacherId: null,
|
||||
}))
|
||||
const values = DEFAULT_CLASS_SUBJECTS.flatMap((name) => {
|
||||
const subjectId = idByName.get(name)
|
||||
if (!subjectId) return []
|
||||
return [{ classId: id, subjectId, teacherId: null }]
|
||||
})
|
||||
await tx.insert(classSubjectTeachers).values(values)
|
||||
})
|
||||
return id
|
||||
@@ -378,8 +388,6 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
|
||||
}
|
||||
}
|
||||
throw new Error("Failed to create class")
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
export async function updateAdminClass(
|
||||
|
||||
@@ -9,23 +9,21 @@ import {
|
||||
classEnrollments,
|
||||
classSchedule,
|
||||
} from "@/shared/db/schema"
|
||||
import {
|
||||
insertClassScheduleItem,
|
||||
updateClassScheduleItemById,
|
||||
deleteClassScheduleItemById,
|
||||
} from "@/modules/scheduling/data-access"
|
||||
import type {
|
||||
ClassScheduleItem,
|
||||
CreateClassScheduleItemInput,
|
||||
StudentScheduleItem,
|
||||
UpdateClassScheduleItemInput,
|
||||
} from "./types"
|
||||
import {
|
||||
getAccessibleClassIdsForTeacher,
|
||||
getSessionTeacherId,
|
||||
getTeacherIdForMutations,
|
||||
} from "./data-access"
|
||||
|
||||
const isWeekday = (n: unknown): n is 1 | 2 | 3 | 4 | 5 | 6 | 7 =>
|
||||
typeof n === "number" && n >= 1 && n <= 7 && Number.isInteger(n)
|
||||
|
||||
const toWeekday = (n: number): 1 | 2 | 3 | 4 | 5 | 6 | 7 =>
|
||||
isWeekday(n) ? n : 1
|
||||
|
||||
export const getStudentSchedule = cache(async (studentId: string): Promise<StudentScheduleItem[]> => {
|
||||
const id = studentId.trim()
|
||||
if (!id) return []
|
||||
@@ -51,7 +49,7 @@ export const getStudentSchedule = cache(async (studentId: string): Promise<Stude
|
||||
id: r.id,
|
||||
classId: r.classId,
|
||||
className: r.className,
|
||||
weekday: r.weekday as StudentScheduleItem["weekday"],
|
||||
weekday: toWeekday(r.weekday),
|
||||
startTime: r.startTime,
|
||||
endTime: r.endTime,
|
||||
course: r.course,
|
||||
@@ -90,7 +88,7 @@ export const getClassSchedule = cache(
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
classId: r.classId,
|
||||
weekday: r.weekday as ClassScheduleItem["weekday"],
|
||||
weekday: toWeekday(r.weekday),
|
||||
startTime: r.startTime,
|
||||
endTime: r.endTime,
|
||||
course: r.course,
|
||||
@@ -98,133 +96,3 @@ export const getClassSchedule = cache(
|
||||
}))
|
||||
}
|
||||
)
|
||||
|
||||
const isTimeHHMM = (v: string) => /^\d{2}:\d{2}$/.test(v)
|
||||
|
||||
export async function createClassScheduleItem(data: CreateClassScheduleItemInput): Promise<string> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
|
||||
const classId = data.classId.trim()
|
||||
const course = data.course.trim()
|
||||
const startTime = data.startTime.trim()
|
||||
const endTime = data.endTime.trim()
|
||||
const location = data.location?.trim() || null
|
||||
const weekday = data.weekday
|
||||
|
||||
if (!classId) throw new Error("Class is required")
|
||||
if (!course) throw new Error("Course is required")
|
||||
if (!isTimeHHMM(startTime) || !isTimeHHMM(endTime)) throw new Error("Invalid time format")
|
||||
if (startTime >= endTime) throw new Error("Start time must be earlier than end time")
|
||||
if (weekday < 1 || weekday > 7) throw new Error("Invalid weekday")
|
||||
|
||||
const [owned] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||
.limit(1)
|
||||
|
||||
if (!owned) throw new Error("Class not found")
|
||||
|
||||
// Delegate DB write to scheduling module (unified write entry point)
|
||||
return insertClassScheduleItem({
|
||||
classId,
|
||||
weekday,
|
||||
startTime,
|
||||
endTime,
|
||||
course,
|
||||
location,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateClassScheduleItem(scheduleId: string, data: UpdateClassScheduleItemInput): Promise<void> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
const id = scheduleId.trim()
|
||||
if (!id) throw new Error("Missing schedule id")
|
||||
|
||||
const [existing] = await db
|
||||
.select({
|
||||
id: classSchedule.id,
|
||||
classId: classSchedule.classId,
|
||||
startTime: classSchedule.startTime,
|
||||
endTime: classSchedule.endTime,
|
||||
})
|
||||
.from(classSchedule)
|
||||
.innerJoin(classes, eq(classes.id, classSchedule.classId))
|
||||
.where(and(eq(classSchedule.id, id), eq(classes.teacherId, teacherId)))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) throw new Error("Schedule item not found")
|
||||
|
||||
const update: Partial<typeof classSchedule.$inferSelect> = {}
|
||||
|
||||
if (typeof data.classId === "string") {
|
||||
const nextClassId = data.classId.trim()
|
||||
if (!nextClassId) throw new Error("Class is required")
|
||||
|
||||
const [ownedNext] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(and(eq(classes.id, nextClassId), eq(classes.teacherId, teacherId)))
|
||||
.limit(1)
|
||||
|
||||
if (!ownedNext) throw new Error("Class not found")
|
||||
update.classId = nextClassId
|
||||
}
|
||||
|
||||
if (typeof data.weekday === "number") {
|
||||
if (data.weekday < 1 || data.weekday > 7) throw new Error("Invalid weekday")
|
||||
update.weekday = data.weekday
|
||||
}
|
||||
|
||||
if (typeof data.course === "string") {
|
||||
const course = data.course.trim()
|
||||
if (!course) throw new Error("Course is required")
|
||||
update.course = course
|
||||
}
|
||||
|
||||
const nextStart = typeof data.startTime === "string" ? data.startTime.trim() : undefined
|
||||
const nextEnd = typeof data.endTime === "string" ? data.endTime.trim() : undefined
|
||||
if (nextStart !== undefined) {
|
||||
if (!isTimeHHMM(nextStart)) throw new Error("Invalid time format")
|
||||
update.startTime = nextStart
|
||||
}
|
||||
if (nextEnd !== undefined) {
|
||||
if (!isTimeHHMM(nextEnd)) throw new Error("Invalid time format")
|
||||
update.endTime = nextEnd
|
||||
}
|
||||
|
||||
if (update.startTime !== undefined || update.endTime !== undefined) {
|
||||
const mergedStart = update.startTime ?? existing.startTime
|
||||
const mergedEnd = update.endTime ?? existing.endTime
|
||||
if (typeof mergedStart === "string" && typeof mergedEnd === "string" && mergedStart >= mergedEnd) {
|
||||
throw new Error("Start time must be earlier than end time")
|
||||
}
|
||||
}
|
||||
|
||||
if (data.location !== undefined) {
|
||||
update.location = data.location?.trim() || null
|
||||
}
|
||||
|
||||
if (Object.keys(update).length === 0) return
|
||||
|
||||
// Delegate DB write to scheduling module (unified write entry point)
|
||||
await updateClassScheduleItemById(id, update)
|
||||
}
|
||||
|
||||
export async function deleteClassScheduleItem(scheduleId: string): Promise<void> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
const id = scheduleId.trim()
|
||||
if (!id) throw new Error("Missing schedule id")
|
||||
|
||||
const [owned] = await db
|
||||
.select({ id: classSchedule.id })
|
||||
.from(classSchedule)
|
||||
.innerJoin(classes, eq(classes.id, classSchedule.classId))
|
||||
.where(and(eq(classSchedule.id, id), eq(classes.teacherId, teacherId)))
|
||||
.limit(1)
|
||||
|
||||
if (!owned) throw new Error("Schedule item not found")
|
||||
|
||||
// Delegate DB write to scheduling module (unified write entry point)
|
||||
await deleteClassScheduleItemById(id)
|
||||
}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import "server-only";
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, asc, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||
import { and, asc, count, eq, inArray } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
classEnrollments,
|
||||
grades,
|
||||
homeworkAssignmentQuestions,
|
||||
homeworkAssignmentTargets,
|
||||
homeworkAssignments,
|
||||
homeworkSubmissions,
|
||||
schools,
|
||||
subjects,
|
||||
exams,
|
||||
} from "@/shared/db/schema"
|
||||
import {
|
||||
getAssignmentIdsForStudents,
|
||||
getAssignmentMaxScoreById,
|
||||
getAssignmentTargetCounts,
|
||||
getHomeworkAssignmentsByIds,
|
||||
getHomeworkAssignmentsWithSubject,
|
||||
getHomeworkSubmissionsForStudents,
|
||||
} from "@/modules/homework/data-access-classes"
|
||||
import type {
|
||||
ClassHomeworkInsights,
|
||||
ClassHomeworkAssignmentStats,
|
||||
@@ -23,6 +25,7 @@ import type {
|
||||
GradeHomeworkInsights,
|
||||
ScoreStats,
|
||||
} from "./types"
|
||||
import type { HomeworkSubmissionRecord } from "@/modules/homework/data-access-classes"
|
||||
import {
|
||||
getAccessibleClassIdsForTeacher,
|
||||
getSessionTeacherId,
|
||||
@@ -52,6 +55,74 @@ const toScoreStats = (scores: number[]): ScoreStats => {
|
||||
}
|
||||
}
|
||||
|
||||
const buildLatestSubmissionByKey = (
|
||||
submissions: HomeworkSubmissionRecord[]
|
||||
): Map<string, HomeworkSubmissionRecord> => {
|
||||
const map = new Map<string, HomeworkSubmissionRecord>()
|
||||
for (const s of submissions) {
|
||||
const key = `${s.assignmentId}:${s.studentId}`
|
||||
if (!map.has(key)) map.set(key, s)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
const computeAssignmentStats = (params: {
|
||||
assignments: Array<{
|
||||
id: string
|
||||
title: string
|
||||
status: string | null
|
||||
createdAt: Date
|
||||
dueAt: Date | null
|
||||
subjectName?: string | null
|
||||
}>
|
||||
studentIds: string[]
|
||||
latestByKey: Map<string, HomeworkSubmissionRecord>
|
||||
maxScoreByAssignmentId: Map<string, number>
|
||||
targetCountByAssignmentId: Map<string, number>
|
||||
}): { stats: ClassHomeworkAssignmentStats[]; allScored: number[] } => {
|
||||
const { assignments, studentIds, latestByKey, maxScoreByAssignmentId, targetCountByAssignmentId } = params
|
||||
const allScored: number[] = []
|
||||
const nowMs = Date.now()
|
||||
|
||||
const stats: ClassHomeworkAssignmentStats[] = assignments.map((a) => {
|
||||
const targetCount = targetCountByAssignmentId.get(a.id) ?? 0
|
||||
let submittedCount = 0
|
||||
let gradedCount = 0
|
||||
const scores: number[] = []
|
||||
const dueMs = a.dueAt ? a.dueAt.getTime() : null
|
||||
|
||||
for (const studentId of studentIds) {
|
||||
const s = latestByKey.get(`${a.id}:${studentId}`)
|
||||
if (!s) continue
|
||||
|
||||
const status = s.status ?? "started"
|
||||
if (status === "submitted" || status === "graded") submittedCount += 1
|
||||
if (status === "graded" || typeof s.score === "number") gradedCount += 1
|
||||
if (typeof s.score === "number") scores.push(s.score)
|
||||
}
|
||||
|
||||
allScored.push(...scores)
|
||||
|
||||
return {
|
||||
assignmentId: a.id,
|
||||
title: a.title,
|
||||
status: a.status ?? "draft",
|
||||
subject: a.subjectName ?? null,
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
||||
isActive: dueMs === null || dueMs >= nowMs,
|
||||
isOverdue: typeof dueMs === "number" && dueMs < nowMs,
|
||||
maxScore: maxScoreByAssignmentId.get(a.id) ?? 0,
|
||||
targetCount,
|
||||
submittedCount,
|
||||
gradedCount,
|
||||
scoreStats: toScoreStats(scores),
|
||||
}
|
||||
})
|
||||
|
||||
return { stats, allScored }
|
||||
}
|
||||
|
||||
export const getClassHomeworkInsights = cache(
|
||||
async (params: { classId: string; teacherId?: string; limit?: number }): Promise<ClassHomeworkInsights | null> => {
|
||||
const teacherId = params.teacherId ?? (await getSessionTeacherId())
|
||||
@@ -127,12 +198,7 @@ export const getClassHomeworkInsights = cache(
|
||||
}
|
||||
}
|
||||
|
||||
const assignmentIdRows = await db
|
||||
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
|
||||
|
||||
const assignmentIds = assignmentIdRows.map((r) => r.assignmentId)
|
||||
const assignmentIds = await getAssignmentIdsForStudents(studentIds)
|
||||
if (assignmentIds.length === 0) {
|
||||
return {
|
||||
class: {
|
||||
@@ -151,26 +217,11 @@ export const getClassHomeworkInsights = cache(
|
||||
}
|
||||
|
||||
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
|
||||
const assignmentConditions: SQL[] = [inArray(homeworkAssignments.id, assignmentIds)]
|
||||
if (subjectIdFilter.length > 0) {
|
||||
assignmentConditions.push(inArray(exams.subjectId, subjectIdFilter))
|
||||
}
|
||||
const assignments = await db
|
||||
.select({
|
||||
id: homeworkAssignments.id,
|
||||
title: homeworkAssignments.title,
|
||||
status: homeworkAssignments.status,
|
||||
createdAt: homeworkAssignments.createdAt,
|
||||
dueAt: homeworkAssignments.dueAt,
|
||||
subjectId: exams.subjectId,
|
||||
subjectName: subjects.name
|
||||
})
|
||||
.from(homeworkAssignments)
|
||||
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
|
||||
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
|
||||
.where(and(...assignmentConditions))
|
||||
.orderBy(desc(homeworkAssignments.createdAt))
|
||||
.limit(limit)
|
||||
const assignments = await getHomeworkAssignmentsWithSubject({
|
||||
assignmentIds,
|
||||
subjectIdFilter: subjectIdFilter.length > 0 ? subjectIdFilter : undefined,
|
||||
limit,
|
||||
})
|
||||
|
||||
const usedAssignmentIds = assignments.map((a) => a.id)
|
||||
if (usedAssignmentIds.length === 0) {
|
||||
@@ -190,86 +241,19 @@ export const getClassHomeworkInsights = cache(
|
||||
}
|
||||
}
|
||||
|
||||
const maxScoreRows = await db
|
||||
.select({
|
||||
assignmentId: homeworkAssignmentQuestions.assignmentId,
|
||||
maxScore: sql<number>`COALESCE(SUM(${homeworkAssignmentQuestions.score}), 0)`,
|
||||
})
|
||||
.from(homeworkAssignmentQuestions)
|
||||
.where(inArray(homeworkAssignmentQuestions.assignmentId, usedAssignmentIds))
|
||||
.groupBy(homeworkAssignmentQuestions.assignmentId)
|
||||
const [maxScoreByAssignmentId, targetCountByAssignmentId, submissions] = await Promise.all([
|
||||
getAssignmentMaxScoreById(usedAssignmentIds),
|
||||
getAssignmentTargetCounts({ assignmentIds: usedAssignmentIds, studentIds }),
|
||||
getHomeworkSubmissionsForStudents({ assignmentIds: usedAssignmentIds, studentIds }),
|
||||
])
|
||||
|
||||
const maxScoreByAssignmentId = new Map<string, number>()
|
||||
for (const r of maxScoreRows) maxScoreByAssignmentId.set(r.assignmentId, Number(r.maxScore ?? 0))
|
||||
|
||||
const targetCountRows = await db
|
||||
.select({
|
||||
assignmentId: homeworkAssignmentTargets.assignmentId,
|
||||
targetCount: sql<number>`COUNT(*)`,
|
||||
})
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(
|
||||
and(
|
||||
inArray(homeworkAssignmentTargets.assignmentId, usedAssignmentIds),
|
||||
inArray(homeworkAssignmentTargets.studentId, studentIds)
|
||||
)
|
||||
)
|
||||
.groupBy(homeworkAssignmentTargets.assignmentId)
|
||||
|
||||
const targetCountByAssignmentId = new Map<string, number>()
|
||||
for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0))
|
||||
|
||||
const submissions = await db.query.homeworkSubmissions.findMany({
|
||||
where: and(
|
||||
inArray(homeworkSubmissions.assignmentId, usedAssignmentIds),
|
||||
inArray(homeworkSubmissions.studentId, studentIds)
|
||||
),
|
||||
orderBy: [desc(homeworkSubmissions.createdAt)],
|
||||
})
|
||||
|
||||
const latestByKey = new Map<string, (typeof submissions)[number]>()
|
||||
for (const s of submissions) {
|
||||
const key = `${s.assignmentId}:${s.studentId}`
|
||||
if (!latestByKey.has(key)) latestByKey.set(key, s)
|
||||
}
|
||||
|
||||
const allScored: number[] = []
|
||||
const nowMs = Date.now()
|
||||
|
||||
const stats: ClassHomeworkAssignmentStats[] = assignments.map((a) => {
|
||||
const targetCount = targetCountByAssignmentId.get(a.id) ?? 0
|
||||
let submittedCount = 0
|
||||
let gradedCount = 0
|
||||
const scores: number[] = []
|
||||
const dueMs = a.dueAt ? a.dueAt.getTime() : null
|
||||
|
||||
for (const studentId of studentIds) {
|
||||
const s = latestByKey.get(`${a.id}:${studentId}`)
|
||||
if (!s) continue
|
||||
|
||||
const status = (s.status ?? "started") as string
|
||||
if (status === "submitted" || status === "graded") submittedCount += 1
|
||||
if (status === "graded" || typeof s.score === "number") gradedCount += 1
|
||||
if (typeof s.score === "number") scores.push(s.score)
|
||||
}
|
||||
|
||||
allScored.push(...scores)
|
||||
|
||||
return {
|
||||
assignmentId: a.id,
|
||||
title: a.title,
|
||||
status: (a.status as string) ?? "draft",
|
||||
subject: a.subjectName,
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
||||
isActive: dueMs === null || dueMs >= nowMs,
|
||||
isOverdue: typeof dueMs === "number" && dueMs < nowMs,
|
||||
maxScore: maxScoreByAssignmentId.get(a.id) ?? 0,
|
||||
targetCount,
|
||||
submittedCount,
|
||||
gradedCount,
|
||||
scoreStats: toScoreStats(scores),
|
||||
}
|
||||
const latestByKey = buildLatestSubmissionByKey(submissions)
|
||||
const { stats, allScored } = computeAssignmentStats({
|
||||
assignments,
|
||||
studentIds,
|
||||
latestByKey,
|
||||
maxScoreByAssignmentId,
|
||||
targetCountByAssignmentId,
|
||||
})
|
||||
|
||||
const overallScores = toScoreStats(allScored)
|
||||
@@ -390,12 +374,7 @@ export const getGradeHomeworkInsights = cache(
|
||||
}
|
||||
}
|
||||
|
||||
const assignmentIdRows = await db
|
||||
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
|
||||
|
||||
const assignmentIds = assignmentIdRows.map((r) => r.assignmentId)
|
||||
const assignmentIds = await getAssignmentIdsForStudents(studentIds)
|
||||
if (assignmentIds.length === 0) {
|
||||
const summaries: GradeHomeworkClassSummary[] = classRows.map((c) => {
|
||||
const bucket = studentsByClassId.get(c.id) ?? { all: new Set<string>(), active: new Set<string>() }
|
||||
@@ -421,11 +400,7 @@ export const getGradeHomeworkInsights = cache(
|
||||
}
|
||||
|
||||
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
|
||||
const assignments = await db.query.homeworkAssignments.findMany({
|
||||
where: inArray(homeworkAssignments.id, assignmentIds),
|
||||
orderBy: [desc(homeworkAssignments.createdAt)],
|
||||
limit,
|
||||
})
|
||||
const assignments = await getHomeworkAssignmentsByIds({ assignmentIds, limit })
|
||||
|
||||
const usedAssignmentIds = assignments.map((a) => a.id)
|
||||
if (usedAssignmentIds.length === 0) {
|
||||
@@ -452,85 +427,19 @@ export const getGradeHomeworkInsights = cache(
|
||||
}
|
||||
}
|
||||
|
||||
const maxScoreRows = await db
|
||||
.select({
|
||||
assignmentId: homeworkAssignmentQuestions.assignmentId,
|
||||
maxScore: sql<number>`COALESCE(SUM(${homeworkAssignmentQuestions.score}), 0)`,
|
||||
})
|
||||
.from(homeworkAssignmentQuestions)
|
||||
.where(inArray(homeworkAssignmentQuestions.assignmentId, usedAssignmentIds))
|
||||
.groupBy(homeworkAssignmentQuestions.assignmentId)
|
||||
const [maxScoreByAssignmentId, targetCountByAssignmentId, submissions] = await Promise.all([
|
||||
getAssignmentMaxScoreById(usedAssignmentIds),
|
||||
getAssignmentTargetCounts({ assignmentIds: usedAssignmentIds, studentIds }),
|
||||
getHomeworkSubmissionsForStudents({ assignmentIds: usedAssignmentIds, studentIds }),
|
||||
])
|
||||
|
||||
const maxScoreByAssignmentId = new Map<string, number>()
|
||||
for (const r of maxScoreRows) maxScoreByAssignmentId.set(r.assignmentId, Number(r.maxScore ?? 0))
|
||||
|
||||
const targetCountRows = await db
|
||||
.select({
|
||||
assignmentId: homeworkAssignmentTargets.assignmentId,
|
||||
targetCount: sql<number>`COUNT(*)`,
|
||||
})
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(
|
||||
and(
|
||||
inArray(homeworkAssignmentTargets.assignmentId, usedAssignmentIds),
|
||||
inArray(homeworkAssignmentTargets.studentId, studentIds)
|
||||
)
|
||||
)
|
||||
.groupBy(homeworkAssignmentTargets.assignmentId)
|
||||
|
||||
const targetCountByAssignmentId = new Map<string, number>()
|
||||
for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0))
|
||||
|
||||
const submissions = await db.query.homeworkSubmissions.findMany({
|
||||
where: and(
|
||||
inArray(homeworkSubmissions.assignmentId, usedAssignmentIds),
|
||||
inArray(homeworkSubmissions.studentId, studentIds)
|
||||
),
|
||||
orderBy: [desc(homeworkSubmissions.createdAt)],
|
||||
})
|
||||
|
||||
const latestByKey = new Map<string, (typeof submissions)[number]>()
|
||||
for (const s of submissions) {
|
||||
const key = `${s.assignmentId}:${s.studentId}`
|
||||
if (!latestByKey.has(key)) latestByKey.set(key, s)
|
||||
}
|
||||
|
||||
const allScored: number[] = []
|
||||
const nowMs = Date.now()
|
||||
|
||||
const stats: ClassHomeworkAssignmentStats[] = assignments.map((a) => {
|
||||
const targetCount = targetCountByAssignmentId.get(a.id) ?? 0
|
||||
let submittedCount = 0
|
||||
let gradedCount = 0
|
||||
const scores: number[] = []
|
||||
const dueMs = a.dueAt ? a.dueAt.getTime() : null
|
||||
|
||||
for (const studentId of studentIds) {
|
||||
const s = latestByKey.get(`${a.id}:${studentId}`)
|
||||
if (!s) continue
|
||||
|
||||
const status = (s.status ?? "started") as string
|
||||
if (status === "submitted" || status === "graded") submittedCount += 1
|
||||
if (status === "graded" || typeof s.score === "number") gradedCount += 1
|
||||
if (typeof s.score === "number") scores.push(s.score)
|
||||
}
|
||||
|
||||
allScored.push(...scores)
|
||||
|
||||
return {
|
||||
assignmentId: a.id,
|
||||
title: a.title,
|
||||
status: (a.status as string) ?? "draft",
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
||||
isActive: dueMs === null || dueMs >= nowMs,
|
||||
isOverdue: typeof dueMs === "number" && dueMs < nowMs,
|
||||
maxScore: maxScoreByAssignmentId.get(a.id) ?? 0,
|
||||
targetCount,
|
||||
submittedCount,
|
||||
gradedCount,
|
||||
scoreStats: toScoreStats(scores),
|
||||
}
|
||||
const latestByKey = buildLatestSubmissionByKey(submissions)
|
||||
const { stats, allScored } = computeAssignmentStats({
|
||||
assignments,
|
||||
studentIds,
|
||||
latestByKey,
|
||||
maxScoreByAssignmentId,
|
||||
targetCountByAssignmentId,
|
||||
})
|
||||
|
||||
const overallScores = toScoreStats(allScored)
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import "server-only";
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, asc, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||
import { and, asc, eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
classEnrollments,
|
||||
homeworkAssignmentTargets,
|
||||
homeworkAssignments,
|
||||
homeworkSubmissions,
|
||||
subjects,
|
||||
exams,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
import {
|
||||
getAssignmentIdsForStudents,
|
||||
getHomeworkSubmissionsForAssignments,
|
||||
getPublishedHomeworkAssignmentsWithSubject,
|
||||
} from "@/modules/homework/data-access-classes"
|
||||
import type {
|
||||
ClassStudent,
|
||||
StudentEnrolledClass,
|
||||
@@ -29,31 +30,12 @@ export const getStudentsSubjectScores = cache(
|
||||
async (studentIds: string[]): Promise<Map<string, Record<string, number | null>>> => {
|
||||
if (studentIds.length === 0) return new Map()
|
||||
|
||||
// 1. Find assignments targeted at these students
|
||||
const assignmentTargets = await db
|
||||
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
|
||||
|
||||
const assignmentIds = Array.from(new Set(assignmentTargets.map(t => t.assignmentId)))
|
||||
// 1. Find assignments targeted at these students (via homework module data-access)
|
||||
const assignmentIds = await getAssignmentIdsForStudents(studentIds)
|
||||
if (assignmentIds.length === 0) return new Map()
|
||||
|
||||
// 2. Get assignment details including subject from linked exam
|
||||
const assignments = await db
|
||||
.select({
|
||||
id: homeworkAssignments.id,
|
||||
createdAt: homeworkAssignments.createdAt,
|
||||
subjectId: exams.subjectId,
|
||||
subjectName: subjects.name
|
||||
})
|
||||
.from(homeworkAssignments)
|
||||
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
|
||||
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
|
||||
.where(and(
|
||||
inArray(homeworkAssignments.id, assignmentIds),
|
||||
eq(homeworkAssignments.status, "published")
|
||||
))
|
||||
.orderBy(desc(homeworkAssignments.createdAt))
|
||||
// 2. Get published assignment details including subject from linked exam (via homework module)
|
||||
const assignments = await getPublishedHomeworkAssignmentsWithSubject({ assignmentIds })
|
||||
|
||||
// 3. Filter subjects (exclude PE, Music, Art)
|
||||
const excludeSubjects = ["体育", "音乐", "美术"]
|
||||
@@ -70,17 +52,8 @@ export const getStudentsSubjectScores = cache(
|
||||
const targetAssignmentIds = Array.from(subjectAssignments.values())
|
||||
if (targetAssignmentIds.length === 0) return new Map()
|
||||
|
||||
// 4. Get submissions for these assignments
|
||||
const submissions = await db
|
||||
.select({
|
||||
studentId: homeworkSubmissions.studentId,
|
||||
assignmentId: homeworkSubmissions.assignmentId,
|
||||
score: homeworkSubmissions.score,
|
||||
createdAt: homeworkSubmissions.createdAt,
|
||||
})
|
||||
.from(homeworkSubmissions)
|
||||
.where(inArray(homeworkSubmissions.assignmentId, targetAssignmentIds))
|
||||
.orderBy(desc(homeworkSubmissions.createdAt))
|
||||
// 4. Get submissions for these assignments (via homework module)
|
||||
const submissions = await getHomeworkSubmissionsForAssignments(targetAssignmentIds)
|
||||
|
||||
// 5. Map back to subject scores per student
|
||||
const studentScores = new Map<string, Record<string, number | null>>()
|
||||
@@ -95,11 +68,11 @@ export const getStudentsSubjectScores = cache(
|
||||
const subject = assignmentSubjectMap.get(s.assignmentId)
|
||||
if (!subject) continue
|
||||
|
||||
if (!studentScores.has(s.studentId)) {
|
||||
studentScores.set(s.studentId, {})
|
||||
const existing = studentScores.get(s.studentId)
|
||||
const scores = existing ?? {}
|
||||
if (!existing) {
|
||||
studentScores.set(s.studentId, scores)
|
||||
}
|
||||
|
||||
const scores = studentScores.get(s.studentId)!
|
||||
// Only set if not already set (since we ordered by desc createdAt, first one is latest)
|
||||
if (scores[subject] === undefined) {
|
||||
scores[subject] = s.score
|
||||
@@ -183,7 +156,8 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
|
||||
.leftJoin(users, eq(users.id, classes.teacherId))
|
||||
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
|
||||
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getStudentClasses primary query failed, falling back:", error)
|
||||
return await db
|
||||
.select({
|
||||
id: classes.id,
|
||||
|
||||
@@ -26,6 +26,12 @@ import type {
|
||||
import { getClassHomeworkInsights } from "./data-access-stats"
|
||||
import { getClassSchedule } from "./data-access-schedule"
|
||||
|
||||
const isClassSubject = (v: unknown): v is ClassSubject =>
|
||||
typeof v === "string" && (DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(v)
|
||||
|
||||
const toClassSubject = (v: string): ClassSubject | null =>
|
||||
isClassSubject(v) ? v : null
|
||||
|
||||
export const getSessionTeacherId = async (): Promise<string | null> => {
|
||||
const { auth } = await import("@/auth")
|
||||
const session = await auth()
|
||||
@@ -118,14 +124,44 @@ export const compareClassLike = (
|
||||
}
|
||||
|
||||
export const getAccessibleClassIdsForTeacher = async (teacherId: string): Promise<string[]> => {
|
||||
const ownedIds = await db.select({ id: classes.id }).from(classes).where(eq(classes.teacherId, teacherId))
|
||||
const assignedIds = await db
|
||||
.select({ id: classSubjectTeachers.classId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(eq(classSubjectTeachers.teacherId, teacherId))
|
||||
const [ownedIds, assignedIds] = await Promise.all([
|
||||
db.select({ id: classes.id }).from(classes).where(eq(classes.teacherId, teacherId)),
|
||||
db
|
||||
.select({ id: classSubjectTeachers.classId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(eq(classSubjectTeachers.teacherId, teacherId)),
|
||||
])
|
||||
return Array.from(new Set([...ownedIds.map((x) => x.id), ...assignedIds.map((x) => x.id)]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that a teacher owns a class (teacherId match on classes row).
|
||||
* Used by scheduling module to gate classSchedule writes.
|
||||
*/
|
||||
export async function verifyTeacherOwnsClass(classId: string, teacherId: string): Promise<boolean> {
|
||||
const [owned] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
|
||||
.limit(1)
|
||||
return Boolean(owned)
|
||||
}
|
||||
|
||||
export const getClassGradeIdsByClassIds = async (classIds: string[]): Promise<Map<string, string>> => {
|
||||
if (classIds.length === 0) return new Map()
|
||||
const rows = await db
|
||||
.select({ id: classes.id, gradeId: classes.gradeId })
|
||||
.from(classes)
|
||||
.where(inArray(classes.id, classIds))
|
||||
const map = new Map<string, string>()
|
||||
for (const row of rows) {
|
||||
if (typeof row.gradeId === "string" && row.gradeId.trim().length > 0) {
|
||||
map.set(row.id, row.gradeId)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export const getTeacherSubjectIdsForClass = async (teacherId: string, classId: string): Promise<string[]> => {
|
||||
const rows = await db
|
||||
.select({ subjectId: classSubjectTeachers.subjectId })
|
||||
@@ -134,6 +170,178 @@ export const getTeacherSubjectIdsForClass = async (teacherId: string, classId: s
|
||||
return Array.from(new Set(rows.map((r) => String(r.subjectId))))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取班级的教师 ID(班主任)。
|
||||
* 供跨模块调用使用,避免直接查询 classes 表。
|
||||
*/
|
||||
export const getClassTeacherById = async (classId: string): Promise<string | null> => {
|
||||
const [row] = await db
|
||||
.select({ teacherId: classes.teacherId })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, classId))
|
||||
.limit(1)
|
||||
return row?.teacherId ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取班级所有学生 ID(不限状态)。
|
||||
* 供跨模块调用使用,避免直接查询 classEnrollments 表。
|
||||
*/
|
||||
export const getStudentIdsByClassId = async (classId: string): Promise<string[]> => {
|
||||
const rows = await db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(eq(classEnrollments.classId, classId))
|
||||
return rows.map((r) => r.studentId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取多个班级的所有学生 ID(不限状态)。
|
||||
* 供跨模块调用使用,避免直接查询 classEnrollments 表。
|
||||
*/
|
||||
export const getStudentIdsByClassIds = async (classIds: string[]): Promise<string[]> => {
|
||||
if (classIds.length === 0) return []
|
||||
const rows = await db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(inArray(classEnrollments.classId, classIds))
|
||||
return Array.from(new Set(rows.map((r) => r.studentId)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取班级所有活跃学生 ID(status = 'active')。
|
||||
* 供跨模块调用使用,避免直接查询 classEnrollments 表。
|
||||
*/
|
||||
export const getActiveStudentIdsByClassId = async (classId: string): Promise<string[]> => {
|
||||
const rows = await db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
|
||||
return rows.map((r) => r.studentId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取教师在一个班级所教的科目 ID 列表。
|
||||
* 参数顺序为 (classId, teacherId),供跨模块调用使用。
|
||||
*/
|
||||
export const getTeacherSubjectIdsByClass = async (classId: string, teacherId: string): Promise<string[]> => {
|
||||
return getTeacherSubjectIdsForClass(teacherId, classId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取学生当前活跃班级的 ID。
|
||||
* 供跨模块调用使用,避免直接查询 classEnrollments 表。
|
||||
*/
|
||||
export const getStudentActiveClassId = async (studentId: string): Promise<string | null> => {
|
||||
const [row] = await db
|
||||
.select({ classId: classEnrollments.classId })
|
||||
.from(classEnrollments)
|
||||
.where(and(eq(classEnrollments.studentId, studentId), eq(classEnrollments.status, "active")))
|
||||
.orderBy(asc(classEnrollments.createdAt))
|
||||
.limit(1)
|
||||
return row?.classId ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取学生当前活跃班级对应的年级 ID。
|
||||
* 供跨模块调用使用,避免直接查询 classEnrollments/classes 表。
|
||||
*/
|
||||
export const getStudentActiveGradeId = async (studentId: string): Promise<string | null> => {
|
||||
const [row] = await db
|
||||
.select({ gradeId: classes.gradeId })
|
||||
.from(classEnrollments)
|
||||
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||
.where(and(eq(classEnrollments.studentId, studentId), eq(classEnrollments.status, "active")))
|
||||
.orderBy(asc(classEnrollments.createdAt))
|
||||
.limit(1)
|
||||
return row?.gradeId ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验班级是否存在。
|
||||
* 供跨模块调用使用,避免直接查询 classes 表。
|
||||
*/
|
||||
export const getClassExists = async (classId: string): Promise<boolean> => {
|
||||
const [row] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, classId))
|
||||
.limit(1)
|
||||
return Boolean(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取班级名称。
|
||||
* 供跨模块调用使用,避免直接查询 classes 表。
|
||||
*/
|
||||
export const getClassNameById = async (classId: string): Promise<string | null> => {
|
||||
const [row] = await db
|
||||
.select({ name: classes.name })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, classId))
|
||||
.limit(1)
|
||||
return row?.name ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取班级关联的年级 ID。
|
||||
* 供跨模块调用使用,避免直接查询 classes 表。
|
||||
*/
|
||||
export const getClassGradeId = async (classId: string): Promise<string | null> => {
|
||||
const [row] = await db
|
||||
.select({ gradeId: classes.gradeId })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, classId))
|
||||
.limit(1)
|
||||
return row?.gradeId ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取多个班级关联的年级 ID 列表(去重,过滤空值)。
|
||||
* 供跨模块调用使用,避免直接查询 classes 表。
|
||||
*/
|
||||
export const getGradeIdsByClassIds = async (classIds: string[]): Promise<string[]> => {
|
||||
if (classIds.length === 0) return []
|
||||
const rows = await db
|
||||
.selectDistinct({ gradeId: classes.gradeId })
|
||||
.from(classes)
|
||||
.where(inArray(classes.id, classIds))
|
||||
return rows
|
||||
.map((r) => r.gradeId)
|
||||
.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取班级名称(Map<classId, name>)。
|
||||
* 供跨模块调用使用,避免直接查询 classes 表。
|
||||
*/
|
||||
export const getClassNamesByIds = async (classIds: string[]): Promise<Map<string, string>> => {
|
||||
const result = new Map<string, string>()
|
||||
const uniqueIds = Array.from(new Set(classIds.filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
if (uniqueIds.length === 0) return result
|
||||
|
||||
const rows = await db
|
||||
.select({ id: classes.id, name: classes.name })
|
||||
.from(classes)
|
||||
.where(inArray(classes.id, uniqueIds))
|
||||
|
||||
for (const r of rows) result.set(r.id, r.name)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定年级下的所有班级(id + name)。
|
||||
* 供跨模块调用使用,避免直接查询 classes 表。
|
||||
*/
|
||||
export const getClassesByGradeId = async (gradeId: string): Promise<Array<{ id: string; name: string }>> => {
|
||||
if (!gradeId) return []
|
||||
const rows = await db
|
||||
.select({ id: classes.id, name: classes.name })
|
||||
.from(classes)
|
||||
.where(eq(classes.gradeId, gradeId))
|
||||
return rows.map((r) => ({ id: r.id, name: r.name }))
|
||||
}
|
||||
|
||||
export const getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise<TeacherClass[]> => {
|
||||
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
|
||||
if (!teacherId) return []
|
||||
@@ -237,8 +445,8 @@ export const getTeacherTeachingSubjects = cache(async (): Promise<ClassSubject[]
|
||||
.orderBy(asc(subjects.name))
|
||||
|
||||
return rows
|
||||
.map((r) => r.subject as ClassSubject)
|
||||
.filter((s) => DEFAULT_CLASS_SUBJECTS.includes(s))
|
||||
.map((r) => toClassSubject(r.subject))
|
||||
.filter((s): s is ClassSubject => s !== null)
|
||||
})
|
||||
|
||||
export async function createTeacherClass(data: CreateTeacherClassInput): Promise<string> {
|
||||
@@ -263,7 +471,11 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
|
||||
.select({ id: subjects.id, name: subjects.name })
|
||||
.from(subjects)
|
||||
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
|
||||
const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
|
||||
const idByName = new Map<ClassSubject, string>()
|
||||
for (const r of subjectRows) {
|
||||
const subject = toClassSubject(r.name)
|
||||
if (subject) idByName.set(subject, r.id)
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(classes).values({
|
||||
@@ -279,13 +491,11 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
|
||||
teacherId,
|
||||
})
|
||||
|
||||
const values = DEFAULT_CLASS_SUBJECTS
|
||||
.filter((name) => idByName.has(name))
|
||||
.map((name) => ({
|
||||
classId: id,
|
||||
subjectId: idByName.get(name)!,
|
||||
teacherId: null,
|
||||
}))
|
||||
const values = DEFAULT_CLASS_SUBJECTS.flatMap((name) => {
|
||||
const subjectId = idByName.get(name)
|
||||
if (!subjectId) return []
|
||||
return [{ classId: id, subjectId, teacherId: null }]
|
||||
})
|
||||
await tx.insert(classSubjectTeachers).values(values)
|
||||
})
|
||||
return id
|
||||
@@ -295,8 +505,6 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
|
||||
}
|
||||
}
|
||||
throw new Error("Failed to create class")
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
export async function ensureClassInvitationCode(classId: string): Promise<string> {
|
||||
@@ -558,15 +766,17 @@ export async function setClassSubjectTeachers(params: {
|
||||
.select({ id: subjects.id, name: subjects.name })
|
||||
.from(subjects)
|
||||
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
|
||||
const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
|
||||
const idByName = new Map<ClassSubject, string>()
|
||||
for (const r of subjectRows) {
|
||||
const subject = toClassSubject(r.name)
|
||||
if (subject) idByName.set(subject, r.id)
|
||||
}
|
||||
|
||||
const values = DEFAULT_CLASS_SUBJECTS
|
||||
.filter((name) => idByName.has(name))
|
||||
.map((name) => ({
|
||||
classId,
|
||||
subjectId: idByName.get(name)!,
|
||||
teacherId: teacherBySubject.get(name) ?? null,
|
||||
}))
|
||||
const values = DEFAULT_CLASS_SUBJECTS.flatMap((name) => {
|
||||
const subjectId = idByName.get(name)
|
||||
if (!subjectId) return []
|
||||
return [{ classId, subjectId, teacherId: teacherBySubject.get(name) ?? null }]
|
||||
})
|
||||
|
||||
await db
|
||||
.insert(classSubjectTeachers)
|
||||
|
||||
157
src/modules/classes/schema.ts
Normal file
157
src/modules/classes/schema.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { z } from "zod"
|
||||
|
||||
// ============ Teacher Class Schemas ============
|
||||
|
||||
/** 教师创建班级 */
|
||||
export const CreateTeacherClassSchema = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
grade: z.string().trim().min(1),
|
||||
schoolName: z.string().nullable().optional(),
|
||||
schoolId: z.string().nullable().optional(),
|
||||
gradeId: z.string().nullable().optional(),
|
||||
homeroom: z.string().nullable().optional(),
|
||||
room: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
export type CreateTeacherClassInput = z.infer<typeof CreateTeacherClassSchema>
|
||||
|
||||
/** 教师更新班级 */
|
||||
export const UpdateTeacherClassSchema = z.object({
|
||||
classId: z.string().trim().min(1),
|
||||
schoolName: z.string().nullable().optional(),
|
||||
schoolId: z.string().nullable().optional(),
|
||||
name: z.string().nullable().optional(),
|
||||
grade: z.string().nullable().optional(),
|
||||
gradeId: z.string().nullable().optional(),
|
||||
homeroom: z.string().nullable().optional(),
|
||||
room: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
export type UpdateTeacherClassInput = z.infer<typeof UpdateTeacherClassSchema>
|
||||
|
||||
/** 教师删除班级 */
|
||||
export const DeleteTeacherClassSchema = z.object({
|
||||
classId: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
export type DeleteTeacherClassInput = z.infer<typeof DeleteTeacherClassSchema>
|
||||
|
||||
// ============ Admin Class Schemas ============
|
||||
|
||||
/** 管理员创建班级 */
|
||||
export const CreateAdminClassSchema = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
grade: z.string().trim().min(1),
|
||||
teacherId: z.string().trim().min(1),
|
||||
schoolName: z.string().nullable().optional(),
|
||||
schoolId: z.string().nullable().optional(),
|
||||
gradeId: z.string().nullable().optional(),
|
||||
homeroom: z.string().nullable().optional(),
|
||||
room: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
export type CreateAdminClassInput = z.infer<typeof CreateAdminClassSchema>
|
||||
|
||||
/** 管理员更新班级 */
|
||||
export const UpdateAdminClassSchema = z.object({
|
||||
classId: z.string().trim().min(1),
|
||||
schoolName: z.string().nullable().optional(),
|
||||
schoolId: z.string().nullable().optional(),
|
||||
name: z.string().nullable().optional(),
|
||||
grade: z.string().nullable().optional(),
|
||||
gradeId: z.string().nullable().optional(),
|
||||
teacherId: z.string().nullable().optional(),
|
||||
homeroom: z.string().nullable().optional(),
|
||||
room: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
export type UpdateAdminClassInput = z.infer<typeof UpdateAdminClassSchema>
|
||||
|
||||
/** 管理员删除班级 */
|
||||
export const DeleteAdminClassSchema = z.object({
|
||||
classId: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
export type DeleteAdminClassInput = z.infer<typeof DeleteAdminClassSchema>
|
||||
|
||||
// ============ Grade Class Schemas ============
|
||||
|
||||
/** 年级主任创建班级 */
|
||||
export const CreateGradeClassSchema = z.object({
|
||||
name: z.string().trim().min(1),
|
||||
gradeId: z.string().trim().min(1),
|
||||
teacherId: z.string().trim().min(1),
|
||||
schoolName: z.string().nullable().optional(),
|
||||
schoolId: z.string().nullable().optional(),
|
||||
grade: z.string().nullable().optional(),
|
||||
homeroom: z.string().nullable().optional(),
|
||||
room: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
export type CreateGradeClassInput = z.infer<typeof CreateGradeClassSchema>
|
||||
|
||||
/** 年级主任更新班级 */
|
||||
export const UpdateGradeClassSchema = z.object({
|
||||
classId: z.string().trim().min(1),
|
||||
schoolName: z.string().nullable().optional(),
|
||||
schoolId: z.string().nullable().optional(),
|
||||
name: z.string().nullable().optional(),
|
||||
grade: z.string().nullable().optional(),
|
||||
gradeId: z.string().nullable().optional(),
|
||||
teacherId: z.string().nullable().optional(),
|
||||
homeroom: z.string().nullable().optional(),
|
||||
room: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
export type UpdateGradeClassInput = z.infer<typeof UpdateGradeClassSchema>
|
||||
|
||||
/** 年级主任删除班级 */
|
||||
export const DeleteGradeClassSchema = z.object({
|
||||
classId: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
export type DeleteGradeClassInput = z.infer<typeof DeleteGradeClassSchema>
|
||||
|
||||
// ============ Class Schedule Item Schemas ============
|
||||
|
||||
/** 创建课表项 */
|
||||
export const CreateClassScheduleItemSchema = z.object({
|
||||
classId: z.string().trim().min(1),
|
||||
weekday: z.coerce.number().int().min(1).max(7),
|
||||
course: z.string().trim().min(1),
|
||||
startTime: z.string().min(1),
|
||||
endTime: z.string().min(1),
|
||||
location: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
export type CreateClassScheduleItemInput = z.infer<typeof CreateClassScheduleItemSchema>
|
||||
|
||||
/** 更新课表项 */
|
||||
export const UpdateClassScheduleItemSchema = z.object({
|
||||
scheduleId: z.string().trim().min(1),
|
||||
classId: z.string().nullable().optional(),
|
||||
weekday: z.coerce.number().int().min(1).max(7).nullable().optional(),
|
||||
course: z.string().nullable().optional(),
|
||||
startTime: z.string().nullable().optional(),
|
||||
endTime: z.string().nullable().optional(),
|
||||
location: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
export type UpdateClassScheduleItemInput = z.infer<typeof UpdateClassScheduleItemSchema>
|
||||
|
||||
/** 删除课表项 */
|
||||
export const DeleteClassScheduleItemSchema = z.object({
|
||||
scheduleId: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
export type DeleteClassScheduleItemInput = z.infer<typeof DeleteClassScheduleItemSchema>
|
||||
|
||||
// ============ Enrollment Schemas ============
|
||||
|
||||
/** 通过邮箱注册学生 */
|
||||
export const EnrollStudentByEmailSchema = z.object({
|
||||
classId: z.string().trim().min(1),
|
||||
email: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
export type EnrollStudentByEmailInput = z.infer<typeof EnrollStudentByEmailSchema>
|
||||
@@ -100,24 +100,6 @@ export type ClassScheduleItem = {
|
||||
location?: string | null
|
||||
}
|
||||
|
||||
export type CreateClassScheduleItemInput = {
|
||||
classId: string
|
||||
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
startTime: string
|
||||
endTime: string
|
||||
course: string
|
||||
location?: string | null
|
||||
}
|
||||
|
||||
export type UpdateClassScheduleItemInput = {
|
||||
classId?: string
|
||||
weekday?: 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
course?: string
|
||||
location?: string | null
|
||||
}
|
||||
|
||||
export type StudentEnrolledClass = {
|
||||
id: string
|
||||
schoolName?: string | null
|
||||
|
||||
@@ -239,6 +239,7 @@ export async function deleteCoursePlanItemAction(
|
||||
try {
|
||||
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
|
||||
await deleteCoursePlanItem(id)
|
||||
revalidatePlanPaths()
|
||||
return { success: true, message: "Week plan deleted" }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
@@ -255,6 +256,7 @@ export async function toggleCoursePlanItemCompletedAction(
|
||||
isCompleted: completed,
|
||||
completedAt: completed ? new Date().toISOString().slice(0, 10) : null,
|
||||
})
|
||||
revalidatePlanPaths()
|
||||
return {
|
||||
success: true,
|
||||
message: completed ? "Marked as completed" : "Marked as incomplete",
|
||||
|
||||
@@ -146,7 +146,7 @@ export const getCoursePlans = cache(
|
||||
if (params?.teacherId) conditions.push(eq(coursePlans.teacherId, params.teacherId))
|
||||
if (params?.subjectId) conditions.push(eq(coursePlans.subjectId, params.subjectId))
|
||||
if (params?.status)
|
||||
conditions.push(eq(coursePlans.status, params.status as CoursePlanStatus))
|
||||
conditions.push(eq(coursePlans.status, params.status))
|
||||
|
||||
const query = buildPlanSelect()
|
||||
const rows = await (conditions.length > 0
|
||||
@@ -155,7 +155,8 @@ export const getCoursePlans = cache(
|
||||
).orderBy(desc(coursePlans.createdAt))
|
||||
|
||||
return rows.map(mapPlanRow)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getCoursePlans failed:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -180,7 +181,8 @@ export const getCoursePlanById = cache(
|
||||
...mapPlanRow(planRow),
|
||||
items: itemRows.map(mapItemRow),
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getCoursePlanById failed:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -314,7 +316,8 @@ export const getSubjectOptions = cache(async (): Promise<{ id: string; name: str
|
||||
.from(subjects)
|
||||
.orderBy(asc(subjects.order), asc(subjects.name))
|
||||
return rows.map((r) => ({ id: r.id, name: r.name }))
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getSubjectOptions failed:", error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13,6 +13,14 @@ import {
|
||||
publishDiagnosticReport,
|
||||
deleteDiagnosticReport,
|
||||
} from "./data-access-reports"
|
||||
import {
|
||||
GenerateStudentReportSchema,
|
||||
GenerateClassReportSchema,
|
||||
PublishReportSchema,
|
||||
DeleteReportSchema,
|
||||
GetDiagnosticReportsSchema,
|
||||
GetDiagnosticReportByIdSchema,
|
||||
} from "./schema"
|
||||
import type { DiagnosticReportQueryParams } from "./types"
|
||||
|
||||
/** 生成学生个人诊断报告 */
|
||||
@@ -23,15 +31,15 @@ export async function generateStudentReportAction(
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
|
||||
|
||||
const studentId = formData.get("studentId")
|
||||
const period = formData.get("period")
|
||||
if (typeof studentId !== "string" || studentId.length === 0) {
|
||||
return { success: false, message: "Missing studentId" }
|
||||
}
|
||||
if (typeof period !== "string" || period.length === 0) {
|
||||
return { success: false, message: "Missing period" }
|
||||
const parsed = GenerateStudentReportSchema.safeParse({
|
||||
studentId: formData.get("studentId"),
|
||||
period: formData.get("period"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing studentId or period" }
|
||||
}
|
||||
|
||||
const { studentId, period } = parsed.data
|
||||
const id = await generateDiagnosticReport(studentId, period, ctx.userId)
|
||||
revalidatePath("/teacher/diagnostic")
|
||||
revalidatePath(`/teacher/diagnostic/student/${studentId}`)
|
||||
@@ -51,15 +59,15 @@ export async function generateClassReportAction(
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
|
||||
|
||||
const classId = formData.get("classId")
|
||||
const period = formData.get("period")
|
||||
if (typeof classId !== "string" || classId.length === 0) {
|
||||
return { success: false, message: "Missing classId" }
|
||||
}
|
||||
if (typeof period !== "string" || period.length === 0) {
|
||||
return { success: false, message: "Missing period" }
|
||||
const parsed = GenerateClassReportSchema.safeParse({
|
||||
classId: formData.get("classId"),
|
||||
period: formData.get("period"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing classId or period" }
|
||||
}
|
||||
|
||||
const { classId, period } = parsed.data
|
||||
const id = await generateClassDiagnosticReport(classId, period, ctx.userId)
|
||||
revalidatePath("/teacher/diagnostic")
|
||||
revalidatePath(`/teacher/diagnostic/class/${classId}`)
|
||||
@@ -79,12 +87,14 @@ export async function publishReportAction(
|
||||
try {
|
||||
await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
|
||||
|
||||
const id = formData.get("id")
|
||||
if (typeof id !== "string" || id.length === 0) {
|
||||
const parsed = PublishReportSchema.safeParse({
|
||||
id: formData.get("id"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing report id" }
|
||||
}
|
||||
|
||||
await publishDiagnosticReport(id)
|
||||
await publishDiagnosticReport(parsed.data.id)
|
||||
revalidatePath("/teacher/diagnostic")
|
||||
return { success: true, message: "Report published" }
|
||||
} catch (e) {
|
||||
@@ -102,12 +112,14 @@ export async function deleteReportAction(
|
||||
try {
|
||||
await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
|
||||
|
||||
const id = formData.get("id")
|
||||
if (typeof id !== "string" || id.length === 0) {
|
||||
const parsed = DeleteReportSchema.safeParse({
|
||||
id: formData.get("id"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing report id" }
|
||||
}
|
||||
|
||||
await deleteDiagnosticReport(id)
|
||||
await deleteDiagnosticReport(parsed.data.id)
|
||||
revalidatePath("/teacher/diagnostic")
|
||||
return { success: true, message: "Report deleted" }
|
||||
} catch (e) {
|
||||
@@ -123,7 +135,13 @@ export async function getDiagnosticReportsAction(
|
||||
): Promise<ActionState<Awaited<ReturnType<typeof getDiagnosticReports>>>> {
|
||||
try {
|
||||
await requirePermission(Permissions.DIAGNOSTIC_READ)
|
||||
const reports = await getDiagnosticReports(params)
|
||||
|
||||
const parsed = GetDiagnosticReportsSchema.safeParse(params)
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Invalid query params" }
|
||||
}
|
||||
|
||||
const reports = await getDiagnosticReports(parsed.data)
|
||||
return { success: true, data: reports }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
@@ -138,7 +156,13 @@ export async function getDiagnosticReportByIdAction(
|
||||
): Promise<ActionState<Awaited<ReturnType<typeof getDiagnosticReportById>>>> {
|
||||
try {
|
||||
await requirePermission(Permissions.DIAGNOSTIC_READ)
|
||||
const report = await getDiagnosticReportById(id)
|
||||
|
||||
const parsed = GetDiagnosticReportByIdSchema.safeParse({ id })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Missing report id" }
|
||||
}
|
||||
|
||||
const report = await getDiagnosticReportById(parsed.data.id)
|
||||
return { success: true, data: report }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import "server-only"
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, desc, eq, inArray } from "drizzle-orm"
|
||||
import { cache } from "react"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { learningDiagnosticReports, users } from "@/shared/db/schema"
|
||||
@@ -19,6 +21,12 @@ const toNumber = (v: unknown): number => {
|
||||
|
||||
const round2 = (n: number): number => Math.round(n * 100) / 100
|
||||
|
||||
const isStringArray = (v: unknown): v is string[] =>
|
||||
Array.isArray(v) && v.every((item) => typeof item === "string")
|
||||
|
||||
const toStringArrayNullable = (v: unknown): string[] | null =>
|
||||
isStringArray(v) ? v : null
|
||||
|
||||
const serializeReport = (r: typeof learningDiagnosticReports.$inferSelect): DiagnosticReport => ({
|
||||
id: r.id,
|
||||
studentId: r.studentId,
|
||||
@@ -26,9 +34,9 @@ const serializeReport = (r: typeof learningDiagnosticReports.$inferSelect): Diag
|
||||
reportType: r.reportType,
|
||||
period: r.period,
|
||||
summary: r.summary,
|
||||
strengths: (r.strengths as string[] | null) ?? null,
|
||||
weaknesses: (r.weaknesses as string[] | null) ?? null,
|
||||
recommendations: (r.recommendations as string[] | null) ?? null,
|
||||
strengths: toStringArrayNullable(r.strengths),
|
||||
weaknesses: toStringArrayNullable(r.weaknesses),
|
||||
recommendations: toStringArrayNullable(r.recommendations),
|
||||
overallScore: r.overallScore !== null ? toNumber(r.overallScore) : null,
|
||||
status: r.status,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
@@ -56,7 +64,6 @@ export async function generateDiagnosticReport(
|
||||
|
||||
const summaryText = `学生 ${summary.studentName} 在 ${period} 期间整体掌握度 ${overallScore.toFixed(1)}%,共评估 ${summary.totalKnowledgePoints} 个知识点,强项 ${strengths.length} 个,弱项 ${weaknesses.length} 个。`
|
||||
|
||||
const { createId } = await import("@paralleldrive/cuid2")
|
||||
const id = createId()
|
||||
await db.insert(learningDiagnosticReports).values({
|
||||
id,
|
||||
@@ -100,7 +107,6 @@ export async function generateClassDiagnosticReport(
|
||||
|
||||
const summaryText = `班级 ${summary.className} 在 ${period} 期间整体掌握度 ${summary.averageMastery.toFixed(1)}%,学生 ${summary.studentCount} 人,需重点关注 ${summary.studentsNeedingAttention.length} 人。`
|
||||
|
||||
const { createId } = await import("@paralleldrive/cuid2")
|
||||
const id = createId()
|
||||
await db.insert(learningDiagnosticReports).values({
|
||||
id,
|
||||
@@ -119,71 +125,71 @@ export async function generateClassDiagnosticReport(
|
||||
}
|
||||
|
||||
/** 查询诊断报告列表 */
|
||||
export async function getDiagnosticReports(
|
||||
filters: DiagnosticReportQueryParams
|
||||
): Promise<DiagnosticReportWithDetails[]> {
|
||||
const conditions = []
|
||||
if (filters.studentId) conditions.push(eq(learningDiagnosticReports.studentId, filters.studentId))
|
||||
if (filters.reportType) conditions.push(eq(learningDiagnosticReports.reportType, filters.reportType))
|
||||
if (filters.status) conditions.push(eq(learningDiagnosticReports.status, filters.status))
|
||||
if (filters.period) conditions.push(eq(learningDiagnosticReports.period, filters.period))
|
||||
export const getDiagnosticReports = cache(
|
||||
async (filters: DiagnosticReportQueryParams): Promise<DiagnosticReportWithDetails[]> => {
|
||||
const conditions = []
|
||||
if (filters.studentId) conditions.push(eq(learningDiagnosticReports.studentId, filters.studentId))
|
||||
if (filters.reportType) conditions.push(eq(learningDiagnosticReports.reportType, filters.reportType))
|
||||
if (filters.status) conditions.push(eq(learningDiagnosticReports.status, filters.status))
|
||||
if (filters.period) conditions.push(eq(learningDiagnosticReports.period, filters.period))
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
report: learningDiagnosticReports,
|
||||
studentName: users.name,
|
||||
})
|
||||
.from(learningDiagnosticReports)
|
||||
.leftJoin(users, eq(users.id, learningDiagnosticReports.studentId))
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(desc(learningDiagnosticReports.createdAt))
|
||||
const rows = await db
|
||||
.select({
|
||||
report: learningDiagnosticReports,
|
||||
studentName: users.name,
|
||||
})
|
||||
.from(learningDiagnosticReports)
|
||||
.leftJoin(users, eq(users.id, learningDiagnosticReports.studentId))
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(desc(learningDiagnosticReports.createdAt))
|
||||
|
||||
const generatorIds = Array.from(
|
||||
new Set(rows.map((r) => r.report.generatedBy).filter((id): id is string => id !== null))
|
||||
)
|
||||
const generatorMap = new Map<string, string>()
|
||||
if (generatorIds.length > 0) {
|
||||
const generators = await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, generatorIds))
|
||||
for (const g of generators) generatorMap.set(g.id, g.name ?? "Unknown")
|
||||
}
|
||||
const generatorIds = Array.from(
|
||||
new Set(rows.map((r) => r.report.generatedBy).filter((id): id is string => id !== null))
|
||||
)
|
||||
const generatorMap = new Map<string, string>()
|
||||
if (generatorIds.length > 0) {
|
||||
const generators = await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, generatorIds))
|
||||
for (const g of generators) generatorMap.set(g.id, g.name ?? "Unknown")
|
||||
}
|
||||
|
||||
return rows.map((r) => ({
|
||||
...serializeReport(r.report),
|
||||
studentName: r.studentName ?? "Unknown",
|
||||
generatedByName: r.report.generatedBy ? generatorMap.get(r.report.generatedBy) ?? "Unknown" : null,
|
||||
}))
|
||||
}
|
||||
return rows.map((r) => ({
|
||||
...serializeReport(r.report),
|
||||
studentName: r.studentName ?? "Unknown",
|
||||
generatedByName: r.report.generatedBy ? generatorMap.get(r.report.generatedBy) ?? "Unknown" : null,
|
||||
}))
|
||||
},
|
||||
)
|
||||
|
||||
/** 获取报告详情 */
|
||||
export async function getDiagnosticReportById(
|
||||
id: string
|
||||
): Promise<DiagnosticReportWithDetails | null> {
|
||||
const [row] = await db
|
||||
.select({ report: learningDiagnosticReports, studentName: users.name })
|
||||
.from(learningDiagnosticReports)
|
||||
.leftJoin(users, eq(users.id, learningDiagnosticReports.studentId))
|
||||
.where(eq(learningDiagnosticReports.id, id))
|
||||
.limit(1)
|
||||
if (!row) return null
|
||||
|
||||
let generatedByName: string | null = null
|
||||
if (row.report.generatedBy) {
|
||||
const [gen] = await db
|
||||
.select({ name: users.name })
|
||||
.from(users)
|
||||
.where(eq(users.id, row.report.generatedBy))
|
||||
export const getDiagnosticReportById = cache(
|
||||
async (id: string): Promise<DiagnosticReportWithDetails | null> => {
|
||||
const [row] = await db
|
||||
.select({ report: learningDiagnosticReports, studentName: users.name })
|
||||
.from(learningDiagnosticReports)
|
||||
.leftJoin(users, eq(users.id, learningDiagnosticReports.studentId))
|
||||
.where(eq(learningDiagnosticReports.id, id))
|
||||
.limit(1)
|
||||
generatedByName = gen?.name ?? null
|
||||
}
|
||||
return {
|
||||
...serializeReport(row.report),
|
||||
studentName: row.studentName ?? "Unknown",
|
||||
generatedByName,
|
||||
}
|
||||
}
|
||||
if (!row) return null
|
||||
|
||||
let generatedByName: string | null = null
|
||||
if (row.report.generatedBy) {
|
||||
const [gen] = await db
|
||||
.select({ name: users.name })
|
||||
.from(users)
|
||||
.where(eq(users.id, row.report.generatedBy))
|
||||
.limit(1)
|
||||
generatedByName = gen?.name ?? null
|
||||
}
|
||||
return {
|
||||
...serializeReport(row.report),
|
||||
studentName: row.studentName ?? "Unknown",
|
||||
generatedByName,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/** 发布诊断报告 */
|
||||
export async function publishDiagnosticReport(id: string): Promise<void> {
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import "server-only"
|
||||
|
||||
import { and, asc, desc, eq, inArray } from "drizzle-orm"
|
||||
import { cache } from "react"
|
||||
import { desc, eq, inArray } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classEnrollments,
|
||||
classes,
|
||||
examSubmissions,
|
||||
knowledgePointMastery,
|
||||
knowledgePoints,
|
||||
questionsToKnowledgePoints,
|
||||
submissionAnswers,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
import { knowledgePointMastery, knowledgePoints } from "@/shared/db/schema"
|
||||
|
||||
import { getClassNameById, getActiveStudentIdsByClassId, getClassExists } from "@/modules/classes/data-access"
|
||||
import { getExamSubmissionWithAnswers } from "@/modules/exams/data-access"
|
||||
import { getKnowledgePointsForQuestions } from "@/modules/questions/data-access"
|
||||
import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access"
|
||||
|
||||
import type {
|
||||
ClassMasterySummary,
|
||||
@@ -42,7 +39,7 @@ const serializeMastery = (r: typeof knowledgePointMastery.$inferSelect): Knowled
|
||||
})
|
||||
|
||||
/** 获取学生在所有知识点的掌握度(含知识点名称) */
|
||||
export async function getStudentMastery(studentId: string): Promise<MasteryWithKnowledgePoint[]> {
|
||||
export const getStudentMastery = cache(async (studentId: string): Promise<MasteryWithKnowledgePoint[]> => {
|
||||
const rows = await db
|
||||
.select({
|
||||
mastery: knowledgePointMastery,
|
||||
@@ -59,11 +56,12 @@ export async function getStudentMastery(studentId: string): Promise<MasteryWithK
|
||||
knowledgePointName: r.kpName ?? "Unknown",
|
||||
knowledgePointDescription: r.kpDescription,
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
/** 获取学生掌握度摘要(含强项/弱项分析) */
|
||||
export async function getStudentMasterySummary(studentId: string): Promise<StudentMasterySummary | null> {
|
||||
const [student] = await db.select({ name: users.name }).from(users).where(eq(users.id, studentId)).limit(1)
|
||||
export const getStudentMasterySummary = cache(async (studentId: string): Promise<StudentMasterySummary | null> => {
|
||||
const userMap = await getUserNamesByIds([studentId])
|
||||
const student = userMap.get(studentId)
|
||||
if (!student) return null
|
||||
|
||||
const allMastery = await getStudentMastery(studentId)
|
||||
@@ -72,53 +70,49 @@ export async function getStudentMasterySummary(studentId: string): Promise<Stude
|
||||
? round2(allMastery.reduce((acc, m) => acc + m.masteryLevel, 0) / allMastery.length)
|
||||
: 0
|
||||
|
||||
// Single-pass classification: strengths (>=80) and weaknesses (<60)
|
||||
const strengths: MasteryWithKnowledgePoint[] = []
|
||||
const weaknesses: MasteryWithKnowledgePoint[] = []
|
||||
for (const m of allMastery) {
|
||||
if (m.masteryLevel >= 80) strengths.push(m)
|
||||
if (m.masteryLevel < 60) weaknesses.push(m)
|
||||
}
|
||||
|
||||
return {
|
||||
studentId,
|
||||
studentName: student.name ?? "Unknown",
|
||||
averageMastery,
|
||||
totalKnowledgePoints: allMastery.length,
|
||||
strengths: allMastery.filter((m) => m.masteryLevel >= 80),
|
||||
weaknesses: allMastery.filter((m) => m.masteryLevel < 60),
|
||||
strengths,
|
||||
weaknesses,
|
||||
allMastery,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** 从提交答案更新掌握度(正确率作为掌握度) */
|
||||
export async function updateMasteryFromSubmission(submissionId: string): Promise<void> {
|
||||
const [submission] = await db
|
||||
.select({ studentId: examSubmissions.studentId })
|
||||
.from(examSubmissions)
|
||||
.where(eq(examSubmissions.id, submissionId))
|
||||
.limit(1)
|
||||
const submission = await getExamSubmissionWithAnswers(submissionId)
|
||||
if (!submission) return
|
||||
|
||||
const answers = await db
|
||||
.select({
|
||||
questionId: submissionAnswers.questionId,
|
||||
score: submissionAnswers.score,
|
||||
})
|
||||
.from(submissionAnswers)
|
||||
.where(eq(submissionAnswers.submissionId, submissionId))
|
||||
|
||||
const answers = submission.answers
|
||||
if (answers.length === 0) return
|
||||
|
||||
const questionIds = Array.from(new Set(answers.map((a) => a.questionId)))
|
||||
const kpLinks = await db
|
||||
.select({
|
||||
questionId: questionsToKnowledgePoints.questionId,
|
||||
knowledgePointId: questionsToKnowledgePoints.knowledgePointId,
|
||||
})
|
||||
.from(questionsToKnowledgePoints)
|
||||
.where(inArray(questionsToKnowledgePoints.questionId, questionIds))
|
||||
const kpMap = await getKnowledgePointsForQuestions(questionIds)
|
||||
|
||||
// Build a Map for O(1) answer lookup instead of find() in loop
|
||||
const answerByQuestionId = new Map(answers.map((a) => [a.questionId, a]))
|
||||
|
||||
const kpStats = new Map<string, { total: number; correct: number }>()
|
||||
for (const link of kpLinks) {
|
||||
const answer = answers.find((a) => a.questionId === link.questionId)
|
||||
for (const [questionId, kpLinks] of kpMap.entries()) {
|
||||
const answer = answerByQuestionId.get(questionId)
|
||||
if (!answer) continue
|
||||
const stat = kpStats.get(link.knowledgePointId) ?? { total: 0, correct: 0 }
|
||||
stat.total += 1
|
||||
if ((answer.score ?? 0) > 0) stat.correct += 1
|
||||
kpStats.set(link.knowledgePointId, stat)
|
||||
for (const link of kpLinks) {
|
||||
const stat = kpStats.get(link.knowledgePointId) ?? { total: 0, correct: 0 }
|
||||
stat.total += 1
|
||||
if ((answer.score ?? 0) > 0) stat.correct += 1
|
||||
kpStats.set(link.knowledgePointId, stat)
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
@@ -147,22 +141,21 @@ export async function updateMasteryFromSubmission(submissionId: string): Promise
|
||||
}
|
||||
|
||||
/** 获取班级掌握度摘要 */
|
||||
export async function getClassMasterySummary(classId: string): Promise<ClassMasterySummary | null> {
|
||||
const [classRow] = await db.select({ id: classes.id, name: classes.name }).from(classes).where(eq(classes.id, classId)).limit(1)
|
||||
if (!classRow) return null
|
||||
export const getClassMasterySummary = cache(async (classId: string): Promise<ClassMasterySummary | null> => {
|
||||
const classExists = await getClassExists(classId)
|
||||
if (!classExists) return null
|
||||
const className = (await getClassNameById(classId)) ?? "Unknown"
|
||||
|
||||
const students = await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(classEnrollments)
|
||||
.innerJoin(users, eq(users.id, classEnrollments.studentId))
|
||||
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
|
||||
.orderBy(asc(users.name))
|
||||
|
||||
if (students.length === 0) {
|
||||
return { classId, className: classRow.name, studentCount: 0, averageMastery: 0, knowledgePointStats: [], studentsNeedingAttention: [] }
|
||||
const studentIds = await getActiveStudentIdsByClassId(classId)
|
||||
if (studentIds.length === 0) {
|
||||
return { classId, className, studentCount: 0, averageMastery: 0, knowledgePointStats: [], studentsNeedingAttention: [] }
|
||||
}
|
||||
|
||||
const studentIds = students.map((s) => s.id)
|
||||
const userMap = await getUserNamesByIds(studentIds)
|
||||
const students = studentIds
|
||||
.map((id) => ({ id, name: userMap.get(id)?.name ?? null }))
|
||||
.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""))
|
||||
|
||||
const masteryRows = await db
|
||||
.select({ mastery: knowledgePointMastery, kpName: knowledgePoints.name })
|
||||
.from(knowledgePointMastery)
|
||||
@@ -203,25 +196,25 @@ export async function getClassMasterySummary(classId: string): Promise<ClassMast
|
||||
|
||||
const studentsNeedingAttention = students
|
||||
.map((s) => {
|
||||
const e = byStudent.get(s.id)!
|
||||
const e = byStudent.get(s.id)
|
||||
if (!e) return null
|
||||
const avg = e.levels.length > 0 ? round2(e.levels.reduce((a, b) => a + b, 0) / e.levels.length) : 0
|
||||
return { studentId: s.id, studentName: s.name ?? "Unknown", averageMastery: avg, weakCount: e.weakCount }
|
||||
})
|
||||
.filter((s): s is { studentId: string; studentName: string; averageMastery: number; weakCount: number } => s !== null)
|
||||
.filter((s) => s.averageMastery < 60)
|
||||
.sort((a, b) => a.averageMastery - b.averageMastery)
|
||||
|
||||
return { classId, className: classRow.name, studentCount: students.length, averageMastery, knowledgePointStats, studentsNeedingAttention }
|
||||
}
|
||||
return { classId, className, studentCount: students.length, averageMastery, knowledgePointStats, studentsNeedingAttention }
|
||||
})
|
||||
|
||||
/** 获取知识点统计(按班级或年级聚合) */
|
||||
export async function getKnowledgePointStats(classId?: string, gradeId?: string): Promise<KnowledgePointStat[]> {
|
||||
export const getKnowledgePointStats = cache(async (classId?: string, gradeId?: string): Promise<KnowledgePointStat[]> => {
|
||||
let studentIds: string[] = []
|
||||
if (classId) {
|
||||
const rows = await db.select({ studentId: classEnrollments.studentId }).from(classEnrollments).where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
|
||||
studentIds = rows.map((r) => r.studentId)
|
||||
studentIds = await getActiveStudentIdsByClassId(classId)
|
||||
} else if (gradeId) {
|
||||
const rows = await db.select({ id: users.id }).from(users).where(eq(users.gradeId, gradeId))
|
||||
studentIds = rows.map((r) => r.id)
|
||||
studentIds = await getUserIdsByGradeId(gradeId)
|
||||
}
|
||||
|
||||
if (studentIds.length === 0) return []
|
||||
@@ -251,4 +244,4 @@ export async function getKnowledgePointStats(classId?: string, gradeId?: string)
|
||||
notMasteredCount: e.notMastered,
|
||||
totalStudents: studentIds.length,
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
48
src/modules/diagnostic/schema.ts
Normal file
48
src/modules/diagnostic/schema.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { z } from "zod"
|
||||
|
||||
/** 生成学生个人诊断报告 */
|
||||
export const GenerateStudentReportSchema = z.object({
|
||||
studentId: z.string().min(1),
|
||||
period: z.string().min(1),
|
||||
})
|
||||
|
||||
export type GenerateStudentReportInput = z.infer<typeof GenerateStudentReportSchema>
|
||||
|
||||
/** 生成班级诊断报告 */
|
||||
export const GenerateClassReportSchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
period: z.string().min(1),
|
||||
})
|
||||
|
||||
export type GenerateClassReportInput = z.infer<typeof GenerateClassReportSchema>
|
||||
|
||||
/** 发布诊断报告 */
|
||||
export const PublishReportSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
})
|
||||
|
||||
export type PublishReportInput = z.infer<typeof PublishReportSchema>
|
||||
|
||||
/** 删除诊断报告 */
|
||||
export const DeleteReportSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
})
|
||||
|
||||
export type DeleteReportInput = z.infer<typeof DeleteReportSchema>
|
||||
|
||||
/** 查询诊断报告列表 */
|
||||
export const GetDiagnosticReportsSchema = z.object({
|
||||
studentId: z.string().optional(),
|
||||
reportType: z.enum(["individual", "class", "grade"]).optional(),
|
||||
status: z.enum(["draft", "published", "archived"]).optional(),
|
||||
period: z.string().optional(),
|
||||
})
|
||||
|
||||
export type GetDiagnosticReportsInput = z.infer<typeof GetDiagnosticReportsSchema>
|
||||
|
||||
/** 获取诊断报告详情 */
|
||||
export const GetDiagnosticReportByIdSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
})
|
||||
|
||||
export type GetDiagnosticReportByIdInput = z.infer<typeof GetDiagnosticReportByIdSchema>
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only"
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, asc, eq, inArray } from "drizzle-orm"
|
||||
import { and, asc, eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
@@ -11,27 +11,36 @@ import {
|
||||
|
||||
import type { CourseSelectionStatus } from "./types"
|
||||
|
||||
function buildLotteryRankCase(ids: string[], startRank: number): SQL {
|
||||
const branches = ids.map(
|
||||
(id, idx) => sql`WHEN ${id} THEN ${startRank + idx}`
|
||||
)
|
||||
return sql`CASE ${courseSelections.id} ${sql.join(branches, sql` `)} END`
|
||||
}
|
||||
|
||||
export async function runLottery(courseId: string): Promise<{
|
||||
enrolled: number
|
||||
waitlist: number
|
||||
}> {
|
||||
const [course] = await db
|
||||
.select()
|
||||
.from(electiveCourses)
|
||||
.where(eq(electiveCourses.id, courseId))
|
||||
.limit(1)
|
||||
if (!course) throw new Error("Course not found")
|
||||
|
||||
const selections = await db
|
||||
.select()
|
||||
.from(courseSelections)
|
||||
.where(
|
||||
and(
|
||||
eq(courseSelections.courseId, courseId),
|
||||
eq(courseSelections.status, "selected")
|
||||
const [courseRows, selections] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(electiveCourses)
|
||||
.where(eq(electiveCourses.id, courseId))
|
||||
.limit(1),
|
||||
db
|
||||
.select()
|
||||
.from(courseSelections)
|
||||
.where(
|
||||
and(
|
||||
eq(courseSelections.courseId, courseId),
|
||||
eq(courseSelections.status, "selected")
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))
|
||||
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt)),
|
||||
])
|
||||
const course = courseRows[0]
|
||||
if (!course) throw new Error("Course not found")
|
||||
|
||||
if (selections.length === 0) {
|
||||
return { enrolled: 0, waitlist: 0 }
|
||||
@@ -41,39 +50,46 @@ export async function runLottery(courseId: string): Promise<{
|
||||
const capacity = course.capacity
|
||||
const now = new Date()
|
||||
|
||||
let enrolledCount = 0
|
||||
let waitlistCount = 0
|
||||
const enrolledIds: string[] = []
|
||||
const waitlistIds: string[] = []
|
||||
for (let i = 0; i < shuffled.length; i++) {
|
||||
const sel = shuffled[i]
|
||||
const rank = i + 1
|
||||
if (i < capacity) {
|
||||
await db
|
||||
.update(courseSelections)
|
||||
.set({
|
||||
status: "enrolled",
|
||||
lotteryRank: rank,
|
||||
enrolledAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(courseSelections.id, sel.id))
|
||||
enrolledCount++
|
||||
enrolledIds.push(shuffled[i].id)
|
||||
} else {
|
||||
await db
|
||||
.update(courseSelections)
|
||||
.set({
|
||||
status: "waitlist",
|
||||
lotteryRank: rank,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(courseSelections.id, sel.id))
|
||||
waitlistCount++
|
||||
waitlistIds.push(shuffled[i].id)
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(electiveCourses)
|
||||
.set({ enrolledCount, status: "closed", updatedAt: now })
|
||||
.where(eq(electiveCourses.id, courseId))
|
||||
const enrolledCount = enrolledIds.length
|
||||
const waitlistCount = waitlistIds.length
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
if (enrolledIds.length > 0) {
|
||||
await tx
|
||||
.update(courseSelections)
|
||||
.set({
|
||||
status: "enrolled",
|
||||
lotteryRank: buildLotteryRankCase(enrolledIds, 1),
|
||||
enrolledAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(inArray(courseSelections.id, enrolledIds))
|
||||
}
|
||||
if (waitlistIds.length > 0) {
|
||||
await tx
|
||||
.update(courseSelections)
|
||||
.set({
|
||||
status: "waitlist",
|
||||
lotteryRank: buildLotteryRankCase(waitlistIds, capacity + 1),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(inArray(courseSelections.id, waitlistIds))
|
||||
}
|
||||
await tx
|
||||
.update(electiveCourses)
|
||||
.set({ enrolledCount, status: "closed", updatedAt: now })
|
||||
.where(eq(electiveCourses.id, courseId))
|
||||
})
|
||||
|
||||
return { enrolled: enrolledCount, waitlist: waitlistCount }
|
||||
}
|
||||
@@ -83,11 +99,25 @@ export async function selectCourse(
|
||||
studentId: string,
|
||||
priority?: number
|
||||
): Promise<{ status: CourseSelectionStatus; message: string }> {
|
||||
const [course] = await db
|
||||
.select()
|
||||
.from(electiveCourses)
|
||||
.where(eq(electiveCourses.id, courseId))
|
||||
.limit(1)
|
||||
const [courseRows, existingRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(electiveCourses)
|
||||
.where(eq(electiveCourses.id, courseId))
|
||||
.limit(1),
|
||||
db
|
||||
.select()
|
||||
.from(courseSelections)
|
||||
.where(
|
||||
and(
|
||||
eq(courseSelections.courseId, courseId),
|
||||
eq(courseSelections.studentId, studentId),
|
||||
inArray(courseSelections.status, ["selected", "enrolled", "waitlist"])
|
||||
)
|
||||
)
|
||||
.limit(1),
|
||||
])
|
||||
const course = courseRows[0]
|
||||
if (!course) throw new Error("Course not found")
|
||||
if (course.status !== "open") throw new Error("Course selection is not open")
|
||||
|
||||
@@ -99,17 +129,7 @@ export async function selectCourse(
|
||||
throw new Error("Selection has ended")
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(courseSelections)
|
||||
.where(
|
||||
and(
|
||||
eq(courseSelections.courseId, courseId),
|
||||
eq(courseSelections.studentId, studentId),
|
||||
inArray(courseSelections.status, ["selected", "enrolled", "waitlist"])
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
const existing = existingRows[0]
|
||||
if (existing) throw new Error("Already selected this course")
|
||||
|
||||
const id = createId()
|
||||
@@ -155,19 +175,28 @@ export async function dropCourse(
|
||||
courseId: string,
|
||||
studentId: string
|
||||
): Promise<void> {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(courseSelections)
|
||||
.where(
|
||||
and(
|
||||
eq(courseSelections.courseId, courseId),
|
||||
eq(courseSelections.studentId, studentId),
|
||||
inArray(courseSelections.status, ["selected", "enrolled", "waitlist"])
|
||||
const [existingRows, courseRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(courseSelections)
|
||||
.where(
|
||||
and(
|
||||
eq(courseSelections.courseId, courseId),
|
||||
eq(courseSelections.studentId, studentId),
|
||||
inArray(courseSelections.status, ["selected", "enrolled", "waitlist"])
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.limit(1),
|
||||
db
|
||||
.select()
|
||||
.from(electiveCourses)
|
||||
.where(eq(electiveCourses.id, courseId))
|
||||
.limit(1),
|
||||
])
|
||||
const existing = existingRows[0]
|
||||
if (!existing) throw new Error("No active selection found")
|
||||
|
||||
const course = courseRows[0]
|
||||
const now = new Date()
|
||||
await db
|
||||
.update(courseSelections)
|
||||
@@ -175,11 +204,6 @@ export async function dropCourse(
|
||||
.where(eq(courseSelections.id, existing.id))
|
||||
|
||||
if (existing.status === "enrolled") {
|
||||
const [course] = await db
|
||||
.select()
|
||||
.from(electiveCourses)
|
||||
.where(eq(electiveCourses.id, courseId))
|
||||
.limit(1)
|
||||
if (course && course.selectionMode === "fcfs") {
|
||||
const newEnrolledCount = Math.max(0, course.enrolledCount - 1)
|
||||
await db
|
||||
|
||||
@@ -1,36 +1,53 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, asc, desc, eq, sql, type SQL } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
classEnrollments,
|
||||
courseSelections,
|
||||
electiveCourses,
|
||||
grades,
|
||||
subjects,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
|
||||
import { getStudentActiveGradeId } from "@/modules/classes/data-access"
|
||||
import { getGradeOptions, getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
|
||||
import type {
|
||||
CourseSelectionStatus,
|
||||
CourseSelectionWithDetails,
|
||||
ElectiveCourseStatus,
|
||||
ElectiveCourseWithDetails,
|
||||
} from "./types"
|
||||
|
||||
type CourseCoreRow = typeof electiveCourses.$inferSelect
|
||||
|
||||
type SelectionCoreRow = {
|
||||
id: string
|
||||
courseId: string
|
||||
studentId: string
|
||||
status: (typeof courseSelections.status.enumValues)[number]
|
||||
priority: number | null
|
||||
selectedAt: Date
|
||||
enrolledAt: Date | null
|
||||
droppedAt: Date | null
|
||||
lotteryRank: number | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
courseName: string | null
|
||||
courseCapacity: number | null
|
||||
courseEnrolledCount: number | null
|
||||
courseStatus: (typeof electiveCourses.status.enumValues)[number] | null
|
||||
}
|
||||
|
||||
const toIso = (d: Date | null | undefined): string | null =>
|
||||
d ? d.toISOString() : null
|
||||
|
||||
const toIsoRequired = (d: Date): string => d.toISOString()
|
||||
|
||||
const mapCourseRow = (
|
||||
r: typeof electiveCourses.$inferSelect & {
|
||||
teacherName: string | null
|
||||
subjectName: string | null
|
||||
gradeName: string | null
|
||||
}
|
||||
r: CourseCoreRow,
|
||||
teacherNames: Map<string, string | null>,
|
||||
subjectNames: Map<string, string>,
|
||||
gradeNames: Map<string, string>
|
||||
): ElectiveCourseWithDetails => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
@@ -51,12 +68,34 @@ const mapCourseRow = (
|
||||
credit: String(r.credit),
|
||||
createdAt: toIsoRequired(r.createdAt),
|
||||
updatedAt: toIsoRequired(r.updatedAt),
|
||||
teacherName: r.teacherName,
|
||||
subjectName: r.subjectName,
|
||||
gradeName: r.gradeName,
|
||||
teacherName: r.teacherId ? (teacherNames.get(r.teacherId) ?? null) : null,
|
||||
subjectName: r.subjectId ? (subjectNames.get(r.subjectId) ?? null) : null,
|
||||
gradeName: r.gradeId ? (gradeNames.get(r.gradeId) ?? null) : null,
|
||||
})
|
||||
|
||||
const buildCourseSelect = () =>
|
||||
const mapSelectionRow = (
|
||||
r: SelectionCoreRow,
|
||||
studentNames: Map<string, string | null>
|
||||
): CourseSelectionWithDetails => ({
|
||||
id: r.id,
|
||||
courseId: r.courseId,
|
||||
studentId: r.studentId,
|
||||
status: r.status,
|
||||
priority: r.priority,
|
||||
selectedAt: toIsoRequired(r.selectedAt),
|
||||
enrolledAt: toIso(r.enrolledAt),
|
||||
droppedAt: toIso(r.droppedAt),
|
||||
lotteryRank: r.lotteryRank,
|
||||
createdAt: toIsoRequired(r.createdAt),
|
||||
updatedAt: toIsoRequired(r.updatedAt),
|
||||
courseName: r.courseName,
|
||||
studentName: r.studentId ? (studentNames.get(r.studentId) ?? null) : null,
|
||||
courseCapacity: r.courseCapacity,
|
||||
courseEnrolledCount: r.courseEnrolledCount,
|
||||
courseStatus: r.courseStatus,
|
||||
})
|
||||
|
||||
const buildCourseCoreSelect = () =>
|
||||
db
|
||||
.select({
|
||||
id: electiveCourses.id,
|
||||
@@ -78,43 +117,10 @@ const buildCourseSelect = () =>
|
||||
credit: electiveCourses.credit,
|
||||
createdAt: electiveCourses.createdAt,
|
||||
updatedAt: electiveCourses.updatedAt,
|
||||
teacherName: users.name,
|
||||
subjectName: subjects.name,
|
||||
gradeName: grades.name,
|
||||
})
|
||||
.from(electiveCourses)
|
||||
.leftJoin(users, eq(users.id, electiveCourses.teacherId))
|
||||
.leftJoin(subjects, eq(subjects.id, electiveCourses.subjectId))
|
||||
.leftJoin(grades, eq(grades.id, electiveCourses.gradeId))
|
||||
|
||||
const mapSelectionRow = (
|
||||
r: typeof courseSelections.$inferSelect & {
|
||||
courseName: string | null
|
||||
studentName: string | null
|
||||
courseCapacity: number | null
|
||||
courseEnrolledCount: number | null
|
||||
courseStatus: (typeof electiveCourses.status.enumValues)[number] | null
|
||||
}
|
||||
): CourseSelectionWithDetails => ({
|
||||
id: r.id,
|
||||
courseId: r.courseId,
|
||||
studentId: r.studentId,
|
||||
status: r.status as CourseSelectionStatus,
|
||||
priority: r.priority,
|
||||
selectedAt: toIsoRequired(r.selectedAt),
|
||||
enrolledAt: toIso(r.enrolledAt),
|
||||
droppedAt: toIso(r.droppedAt),
|
||||
lotteryRank: r.lotteryRank,
|
||||
createdAt: toIsoRequired(r.createdAt),
|
||||
updatedAt: toIsoRequired(r.updatedAt),
|
||||
courseName: r.courseName,
|
||||
studentName: r.studentName,
|
||||
courseCapacity: r.courseCapacity,
|
||||
courseEnrolledCount: r.courseEnrolledCount,
|
||||
courseStatus: r.courseStatus as ElectiveCourseStatus | null,
|
||||
})
|
||||
|
||||
const selectionDetailSelect = () =>
|
||||
const buildSelectionCoreSelect = () =>
|
||||
db
|
||||
.select({
|
||||
id: courseSelections.id,
|
||||
@@ -129,61 +135,91 @@ const selectionDetailSelect = () =>
|
||||
createdAt: courseSelections.createdAt,
|
||||
updatedAt: courseSelections.updatedAt,
|
||||
courseName: electiveCourses.name,
|
||||
studentName: users.name,
|
||||
courseCapacity: electiveCourses.capacity,
|
||||
courseEnrolledCount: electiveCourses.enrolledCount,
|
||||
courseStatus: electiveCourses.status,
|
||||
})
|
||||
.from(courseSelections)
|
||||
.leftJoin(electiveCourses, eq(electiveCourses.id, courseSelections.courseId))
|
||||
.leftJoin(users, eq(users.id, courseSelections.studentId))
|
||||
|
||||
export async function getCourseSelections(
|
||||
courseId: string
|
||||
): Promise<CourseSelectionWithDetails[]> {
|
||||
const rows = await selectionDetailSelect()
|
||||
.where(eq(courseSelections.courseId, courseId))
|
||||
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))
|
||||
return rows.map(mapSelectionRow)
|
||||
}
|
||||
const resolveCourseDisplayNames = async (rows: CourseCoreRow[]): Promise<{
|
||||
teacherNames: Map<string, string | null>
|
||||
subjectNames: Map<string, string>
|
||||
gradeNames: Map<string, string>
|
||||
}> => {
|
||||
const teacherIds = Array.from(new Set(rows.map((r) => r.teacherId).filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
const [userMap, subjects, grades] = await Promise.all([
|
||||
getUserNamesByIds(teacherIds),
|
||||
getSubjectOptions(),
|
||||
getGradeOptions(),
|
||||
])
|
||||
|
||||
export async function getStudentSelections(
|
||||
studentId: string
|
||||
): Promise<CourseSelectionWithDetails[]> {
|
||||
const rows = await selectionDetailSelect()
|
||||
.where(eq(courseSelections.studentId, studentId))
|
||||
.orderBy(desc(courseSelections.selectedAt))
|
||||
return rows.map(mapSelectionRow)
|
||||
}
|
||||
|
||||
export async function getStudentGradeId(studentId: string): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ gradeId: classes.gradeId })
|
||||
.from(classEnrollments)
|
||||
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||
.where(
|
||||
and(
|
||||
eq(classEnrollments.studentId, studentId),
|
||||
eq(classEnrollments.status, "active")
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
return row?.gradeId ?? null
|
||||
}
|
||||
|
||||
export async function getAvailableCoursesForStudent(
|
||||
studentId: string,
|
||||
gradeId?: string | null
|
||||
): Promise<ElectiveCourseWithDetails[]> {
|
||||
const resolvedGradeId = gradeId ?? (await getStudentGradeId(studentId))
|
||||
const conditions: SQL[] = [eq(electiveCourses.status, "open")]
|
||||
if (resolvedGradeId) {
|
||||
conditions.push(
|
||||
sql`(${electiveCourses.gradeId} = ${resolvedGradeId} OR ${electiveCourses.gradeId} IS NULL)`
|
||||
)
|
||||
const teacherNames = new Map<string, string | null>()
|
||||
for (const [id, user] of userMap.entries()) {
|
||||
teacherNames.set(id, user.name)
|
||||
}
|
||||
const rows = await buildCourseSelect()
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(electiveCourses.createdAt))
|
||||
return rows.map(mapCourseRow)
|
||||
const subjectNames = new Map<string, string>()
|
||||
for (const s of subjects) subjectNames.set(s.id, s.name)
|
||||
const gradeNames = new Map<string, string>()
|
||||
for (const g of grades) gradeNames.set(g.id, g.name)
|
||||
|
||||
return { teacherNames, subjectNames, gradeNames }
|
||||
}
|
||||
|
||||
const resolveStudentDisplayNames = async (rows: SelectionCoreRow[]): Promise<Map<string, string | null>> => {
|
||||
const studentIds = Array.from(new Set(rows.map((r) => r.studentId).filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
const userMap = await getUserNamesByIds(studentIds)
|
||||
const studentNames = new Map<string, string | null>()
|
||||
for (const [id, user] of userMap.entries()) {
|
||||
studentNames.set(id, user.name)
|
||||
}
|
||||
return studentNames
|
||||
}
|
||||
|
||||
export const getCourseSelections = cache(
|
||||
async (
|
||||
courseId: string
|
||||
): Promise<CourseSelectionWithDetails[]> => {
|
||||
const rows = await buildSelectionCoreSelect()
|
||||
.where(eq(courseSelections.courseId, courseId))
|
||||
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))
|
||||
const studentNames = await resolveStudentDisplayNames(rows)
|
||||
return rows.map((r) => mapSelectionRow(r, studentNames))
|
||||
}
|
||||
)
|
||||
|
||||
export const getStudentSelections = cache(
|
||||
async (
|
||||
studentId: string
|
||||
): Promise<CourseSelectionWithDetails[]> => {
|
||||
const rows = await buildSelectionCoreSelect()
|
||||
.where(eq(courseSelections.studentId, studentId))
|
||||
.orderBy(desc(courseSelections.selectedAt))
|
||||
const studentNames = await resolveStudentDisplayNames(rows)
|
||||
return rows.map((r) => mapSelectionRow(r, studentNames))
|
||||
}
|
||||
)
|
||||
|
||||
export const getStudentGradeId = cache(async (studentId: string): Promise<string | null> => {
|
||||
return getStudentActiveGradeId(studentId)
|
||||
})
|
||||
|
||||
export const getAvailableCoursesForStudent = cache(
|
||||
async (
|
||||
studentId: string,
|
||||
gradeId?: string | null
|
||||
): Promise<ElectiveCourseWithDetails[]> => {
|
||||
const resolvedGradeId = gradeId ?? (await getStudentGradeId(studentId))
|
||||
const conditions: SQL[] = [eq(electiveCourses.status, "open")]
|
||||
if (resolvedGradeId) {
|
||||
conditions.push(
|
||||
sql`(${electiveCourses.gradeId} = ${resolvedGradeId} OR ${electiveCourses.gradeId} IS NULL)`
|
||||
)
|
||||
}
|
||||
const rows = await buildCourseCoreSelect()
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(electiveCourses.createdAt))
|
||||
const displayMaps = await resolveCourseDisplayNames(rows)
|
||||
return rows.map((r) => mapCourseRow(r, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import type {
|
||||
ElectiveCourseStatus,
|
||||
ElectiveCourseWithDetails,
|
||||
GetElectiveCoursesParams,
|
||||
} from "./types"
|
||||
@@ -114,7 +113,7 @@ export const getElectiveCourses = cache(
|
||||
const conditions: SQL[] = []
|
||||
if (params?.status)
|
||||
conditions.push(
|
||||
eq(electiveCourses.status, params.status as ElectiveCourseStatus)
|
||||
eq(electiveCourses.status, params.status)
|
||||
)
|
||||
if (params?.gradeId) conditions.push(eq(electiveCourses.gradeId, params.gradeId))
|
||||
if (params?.subjectId)
|
||||
@@ -133,7 +132,8 @@ export const getElectiveCourses = cache(
|
||||
).orderBy(desc(electiveCourses.createdAt))
|
||||
|
||||
return rows.map(mapCourseRow)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getElectiveCourses failed:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -147,7 +147,8 @@ export const getElectiveCourseById = cache(
|
||||
.limit(1)
|
||||
if (!row) return null
|
||||
return mapCourseRow(row)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getElectiveCourseById failed:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -234,7 +235,8 @@ export async function getSubjectOptions(): Promise<{ id: string; name: string }[
|
||||
.from(subjects)
|
||||
.orderBy(asc(subjects.order), asc(subjects.name))
|
||||
return rows.map((r) => ({ id: r.id, name: r.name }))
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getSubjectOptions failed:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ export const CourseSelectionStatusEnum = z.enum([
|
||||
"rejected",
|
||||
])
|
||||
|
||||
const emptyToNull = (v: string | undefined | null) =>
|
||||
const emptyToNull = (v: string | undefined | null): string | null =>
|
||||
v && v.length > 0 ? v : null
|
||||
|
||||
const optionalStringToNull = (v: string | undefined | null) =>
|
||||
const optionalStringToNull = (v: string | undefined | null): string | null | undefined =>
|
||||
v === undefined ? undefined : emptyToNull(v)
|
||||
|
||||
export const CreateElectiveCourseSchema = z
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { ActionState } from "@/shared/types/action-state"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { z } from "zod"
|
||||
@@ -267,7 +267,8 @@ export async function createExamAction(
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.EXAM_CREATE)
|
||||
|
||||
const rawQuestions = formData.get("questionsJson") as string | null
|
||||
const rawQuestionsValue = formData.get("questionsJson")
|
||||
const rawQuestions = typeof rawQuestionsValue === "string" ? rawQuestionsValue : null
|
||||
|
||||
const parsed = ExamCreateSchema.safeParse({
|
||||
title: getStringValue(formData, "title"),
|
||||
@@ -346,9 +347,12 @@ export async function createAiExamAction(
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.EXAM_AI_GENERATE)
|
||||
|
||||
const rawQuestions = formData.get("questionsJson") as string | null
|
||||
const rawAiQuestions = formData.get("aiQuestionsJson") as string | null
|
||||
const rawStructure = formData.get("structureJson") as string | null
|
||||
const rawQuestionsValue = formData.get("questionsJson")
|
||||
const rawQuestions = typeof rawQuestionsValue === "string" ? rawQuestionsValue : null
|
||||
const rawAiQuestionsValue = formData.get("aiQuestionsJson")
|
||||
const rawAiQuestions = typeof rawAiQuestionsValue === "string" ? rawAiQuestionsValue : null
|
||||
const rawStructureValue = formData.get("structureJson")
|
||||
const rawStructure = typeof rawStructureValue === "string" ? rawStructureValue : null
|
||||
const aiSourceTextRaw = formData.get("aiSourceText")
|
||||
const aiQuestionCountRaw = formData.get("aiQuestionCount")
|
||||
const aiProviderIdRaw = formData.get("aiProviderId")
|
||||
|
||||
@@ -461,16 +461,19 @@ const splitStructureItems = (draft: z.infer<typeof AiStructureResponseSchema>) =
|
||||
} satisfies SplitQuestionItem))
|
||||
}
|
||||
const rows: SplitQuestionItem[] = []
|
||||
draft.sections!.forEach((section, sectionIndex) => {
|
||||
section.questions.forEach((q) => {
|
||||
rows.push({
|
||||
sectionIndex,
|
||||
sectionTitle: section.title,
|
||||
text: q.text,
|
||||
score: q.score,
|
||||
const sections = draft.sections
|
||||
if (sections) {
|
||||
sections.forEach((section, sectionIndex) => {
|
||||
section.questions.forEach((q) => {
|
||||
rows.push({
|
||||
sectionIndex,
|
||||
sectionTitle: section.title,
|
||||
text: q.text,
|
||||
score: q.score,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
@@ -654,15 +657,16 @@ const buildPreviewPayload = (
|
||||
}
|
||||
): AiPreviewData => {
|
||||
const hasSections = Array.isArray(aiParsed.sections) && aiParsed.sections.length > 0
|
||||
const baseQuestions = hasSections ? aiParsed.sections!.flatMap((s) => s.questions) : aiParsed.questions ?? []
|
||||
const baseQuestions = hasSections ? (aiParsed.sections ?? []).flatMap((s) => s.questions) : aiParsed.questions ?? []
|
||||
const limit = input.questionCount
|
||||
let sections = aiParsed.sections
|
||||
let flatQuestions = baseQuestions
|
||||
|
||||
if (typeof limit === "number" && limit > 0) {
|
||||
if (hasSections) {
|
||||
const parsedSections = aiParsed.sections
|
||||
let remaining = limit
|
||||
sections = aiParsed.sections!.map((s) => {
|
||||
sections = (parsedSections ?? []).map((s) => {
|
||||
if (remaining <= 0) return { ...s, questions: [] }
|
||||
const sliced = s.questions.slice(0, remaining)
|
||||
remaining -= sliced.length
|
||||
|
||||
@@ -86,15 +86,16 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
pageSize: 20
|
||||
})
|
||||
|
||||
if (result && result.data) {
|
||||
if (result.success && result.data) {
|
||||
const questionsList = result.data.data
|
||||
setBankQuestions(prev => {
|
||||
if (reset) return result.data
|
||||
if (reset) return questionsList
|
||||
// Deduplicate just in case
|
||||
const existingIds = new Set(prev.map(q => q.id))
|
||||
const newQuestions = result.data.filter(q => !existingIds.has(q.id))
|
||||
const newQuestions = questionsList.filter(q => !existingIds.has(q.id))
|
||||
return [...prev, ...newQuestions]
|
||||
})
|
||||
setHasMore(result.data.length === 20)
|
||||
setHasMore(questionsList.length === 20)
|
||||
setPage(nextPage)
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { db } from "@/shared/db"
|
||||
import { exams, examQuestions, questions, subjects, grades, classes } from "@/shared/db/schema"
|
||||
import { exams, examQuestions, examSubmissions, submissionAnswers, subjects, grades } from "@/shared/db/schema"
|
||||
import { count, eq, desc, like, and, or, inArray } from "drizzle-orm"
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { createQuestionWithRelations } from "@/modules/questions/data-access"
|
||||
import { getClassGradeIdsByClassIds } from "@/modules/classes/data-access"
|
||||
|
||||
import type { Exam, ExamDifficulty, ExamStatus } from "./types"
|
||||
import type { AiGeneratedQuestion, AiGeneratedStructureNode } from "./ai-pipeline"
|
||||
@@ -45,6 +47,12 @@ const getStringArray = (obj: Record<string, unknown>, key: string): string[] | u
|
||||
return items.length === v.length ? items : undefined
|
||||
}
|
||||
|
||||
const isExamStatus = (v: unknown): v is ExamStatus =>
|
||||
v === "draft" || v === "published" || v === "archived"
|
||||
|
||||
const toExamStatus = (v: string | null | undefined): ExamStatus =>
|
||||
isExamStatus(v) ? v : "draft"
|
||||
|
||||
const toExamDifficulty = (n: number | undefined): ExamDifficulty => {
|
||||
if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) return n
|
||||
return 1
|
||||
@@ -69,11 +77,8 @@ export const getExams = cache(async (params: GetExamsParams & { scope: DataScope
|
||||
}
|
||||
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
|
||||
// Teacher can see exams for grades their classes belong to
|
||||
const teacherGradeIds = await db
|
||||
.selectDistinct({ gradeId: classes.gradeId })
|
||||
.from(classes)
|
||||
.where(inArray(classes.id, params.scope.classIds))
|
||||
const gradeIds = teacherGradeIds.map(g => g.gradeId).filter(Boolean) as string[]
|
||||
const classGradeMap = await getClassGradeIdsByClassIds(params.scope.classIds)
|
||||
const gradeIds = Array.from(new Set(classGradeMap.values()))
|
||||
if (gradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, gradeIds))
|
||||
}
|
||||
@@ -105,7 +110,7 @@ export const getExams = cache(async (params: GetExamsParams & { scope: DataScope
|
||||
return {
|
||||
id: exam.id,
|
||||
title: exam.title,
|
||||
status: (exam.status as ExamStatus) || "draft",
|
||||
status: toExamStatus(exam.status),
|
||||
subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
|
||||
grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
|
||||
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
|
||||
@@ -153,11 +158,8 @@ export const getExamById = cache(async (id: string, scope?: DataScope) => {
|
||||
return null
|
||||
}
|
||||
if (scope.type === "class_taught" && scope.classIds.length > 0) {
|
||||
const teacherGradeIds = await db
|
||||
.selectDistinct({ gradeId: classes.gradeId })
|
||||
.from(classes)
|
||||
.where(inArray(classes.id, scope.classIds))
|
||||
const gradeIds = teacherGradeIds.map(g => g.gradeId).filter(Boolean) as string[]
|
||||
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
|
||||
const gradeIds = Array.from(new Set(classGradeMap.values()))
|
||||
if (gradeIds.length > 0 && !gradeIds.includes(exam.gradeId ?? "")) {
|
||||
return null
|
||||
}
|
||||
@@ -169,7 +171,7 @@ export const getExamById = cache(async (id: string, scope?: DataScope) => {
|
||||
return {
|
||||
id: exam.id,
|
||||
title: exam.title,
|
||||
status: (exam.status as ExamStatus) || "draft",
|
||||
status: toExamStatus(exam.status),
|
||||
subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
|
||||
grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
|
||||
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
|
||||
@@ -191,9 +193,9 @@ export const getExamById = cache(async (id: string, scope?: DataScope) => {
|
||||
export const omitScheduledAtFromDescription = (description: string | null): string => {
|
||||
if (!description) return "{}"
|
||||
try {
|
||||
const meta = JSON.parse(description)
|
||||
if (typeof meta === "object" && meta !== null) {
|
||||
const rest = { ...(meta as Record<string, unknown>) }
|
||||
const parsed: unknown = JSON.parse(description)
|
||||
if (isRecord(parsed)) {
|
||||
const rest = { ...parsed }
|
||||
delete rest.scheduledAt
|
||||
return JSON.stringify(rest)
|
||||
}
|
||||
@@ -299,8 +301,31 @@ export const persistAiGeneratedExamDraft = async (input: {
|
||||
description: string
|
||||
structure: AiGeneratedStructureNode[]
|
||||
generated: AiGeneratedQuestion[]
|
||||
}) => {
|
||||
}): Promise<void> => {
|
||||
const orderedQuestions = buildOrderedQuestionsFromStructure(input.structure, input.generated)
|
||||
|
||||
// P0-1 fix: create questions via questions module data-access instead of direct table insert.
|
||||
// createQuestionWithRelations generates new IDs, so we remap structure references accordingly.
|
||||
const questionIdMapping = new Map<string, string>()
|
||||
for (const q of input.generated) {
|
||||
const newQuestionId = await createQuestionWithRelations(
|
||||
{
|
||||
content: q.content,
|
||||
type: q.type,
|
||||
difficulty: q.difficulty,
|
||||
},
|
||||
input.creatorId
|
||||
)
|
||||
questionIdMapping.set(q.id, newQuestionId)
|
||||
}
|
||||
|
||||
const remappedOrderedQuestions = orderedQuestions
|
||||
.map((q) => {
|
||||
const mappedId = questionIdMapping.get(q.id)
|
||||
return mappedId ? { id: mappedId, score: q.score } : null
|
||||
})
|
||||
.filter((q): q is { id: string; score: number } => q !== null)
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(exams).values({
|
||||
id: input.examId,
|
||||
@@ -314,21 +339,9 @@ export const persistAiGeneratedExamDraft = async (input: {
|
||||
structure: input.structure,
|
||||
})
|
||||
|
||||
if (input.generated.length > 0) {
|
||||
await tx.insert(questions).values(
|
||||
input.generated.map((q) => ({
|
||||
id: q.id,
|
||||
content: q.content,
|
||||
type: q.type,
|
||||
difficulty: q.difficulty,
|
||||
authorId: input.creatorId,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
if (orderedQuestions.length > 0) {
|
||||
if (remappedOrderedQuestions.length > 0) {
|
||||
await tx.insert(examQuestions).values(
|
||||
orderedQuestions.map((q, idx) => ({
|
||||
remappedOrderedQuestions.map((q, idx) => ({
|
||||
examId: input.examId,
|
||||
questionId: q.id,
|
||||
score: q.score ?? 0,
|
||||
@@ -354,11 +367,8 @@ export const getExamsDashboardStats = cache(async (scope?: DataScope): Promise<E
|
||||
conditions.push(inArray(exams.gradeId, scope.gradeIds))
|
||||
}
|
||||
if (scope.type === "class_taught" && scope.classIds.length > 0) {
|
||||
const teacherGradeIds = await db
|
||||
.selectDistinct({ gradeId: classes.gradeId })
|
||||
.from(classes)
|
||||
.where(inArray(classes.id, scope.classIds))
|
||||
const gradeIds = teacherGradeIds.map((g) => g.gradeId).filter(Boolean) as string[]
|
||||
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
|
||||
const gradeIds = Array.from(new Set(classGradeMap.values()))
|
||||
if (gradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, gradeIds))
|
||||
}
|
||||
@@ -522,3 +532,193 @@ export const getExamGrades = async (): Promise<Array<{ id: string; name: string
|
||||
})
|
||||
return allGrades.map((g) => ({ id: g.id, name: g.name }))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-module query interfaces (供其他模块调用,避免直查 exams/examSubmissions 表)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 获取指定年级 ID 列表对应的所有考试 ID。
|
||||
* 供 homework/grades 等模块跨模块调用使用。
|
||||
*/
|
||||
export const getExamIdsByGradeIds = async (gradeIds: string[]): Promise<string[]> => {
|
||||
if (gradeIds.length === 0) return []
|
||||
const rows = await db
|
||||
.select({ id: exams.id })
|
||||
.from(exams)
|
||||
.where(inArray(exams.gradeId, gradeIds))
|
||||
return rows.map((r) => r.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取考试的基本信息(含题目列表),供 homework 模块创建作业时使用。
|
||||
* 返回的数据包含 examId、title、subjectId、structure 和题目列表。
|
||||
*/
|
||||
export type ExamWithQuestionsForHomework = {
|
||||
id: string
|
||||
title: string
|
||||
subjectId: string | null
|
||||
structure: unknown
|
||||
questions: Array<{ questionId: string; score: number | null; order: number | null }>
|
||||
}
|
||||
|
||||
export const getExamWithQuestionsForHomework = async (
|
||||
examId: string
|
||||
): Promise<ExamWithQuestionsForHomework | null> => {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
with: {
|
||||
questions: {
|
||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!exam) return null
|
||||
return {
|
||||
id: exam.id,
|
||||
title: exam.title,
|
||||
subjectId: exam.subjectId,
|
||||
structure: exam.structure,
|
||||
questions: exam.questions.map((q) => ({
|
||||
questionId: q.questionId,
|
||||
score: q.score ?? null,
|
||||
order: q.order ?? null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取多个考试的 subjectId 映射(examId -> subjectId)。
|
||||
* 供 homework 模块查询作业对应科目时使用。
|
||||
*/
|
||||
export const getExamSubjectIdMap = async (examIds: string[]): Promise<Map<string, string | null>> => {
|
||||
if (examIds.length === 0) return new Map()
|
||||
const rows = await db
|
||||
.select({ id: exams.id, subjectId: exams.subjectId })
|
||||
.from(exams)
|
||||
.where(inArray(exams.id, examIds))
|
||||
const map = new Map<string, string | null>()
|
||||
for (const r of rows) map.set(r.id, r.subjectId)
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取考试标题。
|
||||
* 供 proctoring 等模块跨模块调用使用。
|
||||
*/
|
||||
export const getExamTitleById = async (examId: string): Promise<string | null> => {
|
||||
const [row] = await db
|
||||
.select({ title: exams.title })
|
||||
.from(exams)
|
||||
.where(eq(exams.id, examId))
|
||||
.limit(1)
|
||||
return row?.title ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取考试的基本信息(含监考模式相关字段),供 proctoring 模块使用。
|
||||
*/
|
||||
export type ExamForProctoring = {
|
||||
id: string
|
||||
title: string
|
||||
examMode: string | null
|
||||
durationMinutes: number | null
|
||||
shuffleQuestions: boolean | null
|
||||
allowLateStart: boolean | null
|
||||
lateStartGraceMinutes: number | null
|
||||
antiCheatEnabled: boolean | null
|
||||
}
|
||||
|
||||
export const getExamForProctoringCrossModule = async (examId: string): Promise<ExamForProctoring | null> => {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
})
|
||||
if (!exam) return null
|
||||
return {
|
||||
id: exam.id,
|
||||
title: exam.title,
|
||||
examMode: exam.examMode,
|
||||
durationMinutes: exam.durationMinutes ?? null,
|
||||
shuffleQuestions: exam.shuffleQuestions ?? false,
|
||||
allowLateStart: exam.allowLateStart ?? false,
|
||||
lateStartGraceMinutes: exam.lateStartGraceMinutes ?? 0,
|
||||
antiCheatEnabled: exam.antiCheatEnabled ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验提交记录归属(监考事件上报前的安全校验)。
|
||||
* 供 proctoring 模块跨模块调用使用。
|
||||
*/
|
||||
export const getExamSubmissionForProctoringCrossModule = async (
|
||||
submissionId: string,
|
||||
studentId: string
|
||||
): Promise<{ id: string; examId: string; studentId: string } | null> => {
|
||||
const submission = await db.query.examSubmissions.findFirst({
|
||||
where: and(
|
||||
eq(examSubmissions.id, submissionId),
|
||||
eq(examSubmissions.studentId, studentId),
|
||||
),
|
||||
columns: {
|
||||
id: true,
|
||||
examId: true,
|
||||
studentId: true,
|
||||
},
|
||||
})
|
||||
return submission ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取考试提交记录及其答题数据,供 diagnostic 模块更新知识点掌握度使用。
|
||||
*/
|
||||
export type ExamSubmissionWithAnswers = {
|
||||
studentId: string
|
||||
answers: Array<{ questionId: string; score: number | null }>
|
||||
}
|
||||
|
||||
export const getExamSubmissionWithAnswers = async (
|
||||
submissionId: string
|
||||
): Promise<ExamSubmissionWithAnswers | null> => {
|
||||
const [submission] = await db
|
||||
.select({ studentId: examSubmissions.studentId })
|
||||
.from(examSubmissions)
|
||||
.where(eq(examSubmissions.id, submissionId))
|
||||
.limit(1)
|
||||
if (!submission) return null
|
||||
|
||||
const answers = await db
|
||||
.select({
|
||||
questionId: submissionAnswers.questionId,
|
||||
score: submissionAnswers.score,
|
||||
})
|
||||
.from(submissionAnswers)
|
||||
.where(eq(submissionAnswers.submissionId, submissionId))
|
||||
|
||||
return {
|
||||
studentId: submission.studentId,
|
||||
answers,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取一场考试的所有提交记录(含学生 ID 和状态),供 proctoring 模块使用。
|
||||
*/
|
||||
export type ExamSubmissionForProctoringSummary = {
|
||||
id: string
|
||||
studentId: string
|
||||
status: string | null
|
||||
}
|
||||
|
||||
export const getExamSubmissionsForExam = async (
|
||||
examId: string
|
||||
): Promise<ExamSubmissionForProctoringSummary[]> => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: examSubmissions.id,
|
||||
studentId: examSubmissions.studentId,
|
||||
status: examSubmissions.status,
|
||||
})
|
||||
.from(examSubmissions)
|
||||
.where(eq(examSubmissions.examId, examId))
|
||||
return rows
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import "server-only"
|
||||
|
||||
import { and, count, desc, eq, inArray, like, or, sql } from "drizzle-orm"
|
||||
import { cache } from "react"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { fileAttachments } from "@/shared/db/schema"
|
||||
@@ -50,7 +51,8 @@ export async function createFileAttachment(
|
||||
|
||||
const created = await getFileAttachment(data.id)
|
||||
return created
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("createFileAttachment failed:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -58,80 +60,87 @@ export async function createFileAttachment(
|
||||
/**
|
||||
* 按 ID 查询文件附件
|
||||
*/
|
||||
export async function getFileAttachment(id: string): Promise<FileAttachment | null> {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(fileAttachments)
|
||||
.where(eq(fileAttachments.id, id))
|
||||
.limit(1)
|
||||
export const getFileAttachment = cache(
|
||||
async (id: string): Promise<FileAttachment | null> => {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(fileAttachments)
|
||||
.where(eq(fileAttachments.id, id))
|
||||
.limit(1)
|
||||
|
||||
return row ? mapRow(row) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return row ? mapRow(row) : null
|
||||
} catch (error) {
|
||||
console.error("getFileAttachment failed:", error)
|
||||
return null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 按关联资源查询文件列表
|
||||
*/
|
||||
export async function getFileAttachmentsByTarget(
|
||||
targetType: string,
|
||||
targetId: string
|
||||
): Promise<FileAttachment[]> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(fileAttachments)
|
||||
.where(
|
||||
and(
|
||||
eq(fileAttachments.targetType, targetType),
|
||||
eq(fileAttachments.targetId, targetId)
|
||||
export const getFileAttachmentsByTarget = cache(
|
||||
async (targetType: string, targetId: string): Promise<FileAttachment[]> => {
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(fileAttachments)
|
||||
.where(
|
||||
and(
|
||||
eq(fileAttachments.targetType, targetType),
|
||||
eq(fileAttachments.targetId, targetId)
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderBy(desc(fileAttachments.createdAt))
|
||||
.orderBy(desc(fileAttachments.createdAt))
|
||||
|
||||
return rows.map(mapRow)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return rows.map(mapRow)
|
||||
} catch (error) {
|
||||
console.error("getFileAttachmentsByTarget failed:", error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 按上传者查询文件列表
|
||||
*/
|
||||
export async function getFileAttachmentsByUploader(
|
||||
uploaderId: string
|
||||
): Promise<FileAttachment[]> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(fileAttachments)
|
||||
.where(eq(fileAttachments.uploaderId, uploaderId))
|
||||
.orderBy(desc(fileAttachments.createdAt))
|
||||
export const getFileAttachmentsByUploader = cache(
|
||||
async (uploaderId: string): Promise<FileAttachment[]> => {
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(fileAttachments)
|
||||
.where(eq(fileAttachments.uploaderId, uploaderId))
|
||||
.orderBy(desc(fileAttachments.createdAt))
|
||||
|
||||
return rows.map(mapRow)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return rows.map(mapRow)
|
||||
} catch (error) {
|
||||
console.error("getFileAttachmentsByUploader failed:", error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 查询所有文件(用于管理员文件管理页面)
|
||||
*/
|
||||
export async function getAllFileAttachments(limit = 100): Promise<FileAttachment[]> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(fileAttachments)
|
||||
.orderBy(desc(fileAttachments.createdAt))
|
||||
.limit(limit)
|
||||
export const getAllFileAttachments = cache(
|
||||
async (limit = 100): Promise<FileAttachment[]> => {
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(fileAttachments)
|
||||
.orderBy(desc(fileAttachments.createdAt))
|
||||
.limit(limit)
|
||||
|
||||
return rows.map(mapRow)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return rows.map(mapRow)
|
||||
} catch (error) {
|
||||
console.error("getAllFileAttachments failed:", error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 删除文件附件记录
|
||||
@@ -140,7 +149,8 @@ export async function deleteFileAttachment(id: string): Promise<boolean> {
|
||||
try {
|
||||
await db.delete(fileAttachments).where(eq(fileAttachments.id, id))
|
||||
return true
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("deleteFileAttachment failed:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -156,7 +166,8 @@ export async function deleteFileAttachments(ids: string[]): Promise<BatchDeleteR
|
||||
try {
|
||||
await db.delete(fileAttachments).where(inArray(fileAttachments.id, ids))
|
||||
return { success: true, deletedCount: ids.length, failedIds: [] }
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("deleteFileAttachments batch failed:", error)
|
||||
// 失败时回退到逐条删除,尽量多删
|
||||
const failedIds: string[] = []
|
||||
let deletedCount = 0
|
||||
@@ -164,7 +175,8 @@ export async function deleteFileAttachments(ids: string[]): Promise<BatchDeleteR
|
||||
try {
|
||||
await db.delete(fileAttachments).where(eq(fileAttachments.id, id))
|
||||
deletedCount += 1
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error("deleteFileAttachments single failed:", err)
|
||||
failedIds.push(id)
|
||||
}
|
||||
}
|
||||
@@ -181,87 +193,93 @@ export async function deleteFileAttachments(ids: string[]): Promise<BatchDeleteR
|
||||
* - mimeType: 精确匹配或前缀匹配(如 "image/")
|
||||
* - search: 在 originalName / filename 中模糊匹配
|
||||
*/
|
||||
export async function getFileAttachmentsWithFilters(
|
||||
params: FileAttachmentQueryParams
|
||||
): Promise<FileAttachment[]> {
|
||||
try {
|
||||
const { mimeType, search, limit = 100, offset = 0 } = params
|
||||
export const getFileAttachmentsWithFilters = cache(
|
||||
async (params: FileAttachmentQueryParams): Promise<FileAttachment[]> => {
|
||||
try {
|
||||
const { mimeType, search, limit = 100, offset = 0 } = params
|
||||
|
||||
const conditions = []
|
||||
if (mimeType) {
|
||||
if (mimeType.endsWith("/")) {
|
||||
conditions.push(like(fileAttachments.mimeType, `${mimeType}%`))
|
||||
} else {
|
||||
conditions.push(eq(fileAttachments.mimeType, mimeType))
|
||||
const conditions = []
|
||||
if (mimeType) {
|
||||
if (mimeType.endsWith("/")) {
|
||||
conditions.push(like(fileAttachments.mimeType, `${mimeType}%`))
|
||||
} else {
|
||||
conditions.push(eq(fileAttachments.mimeType, mimeType))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (search) {
|
||||
const kw = `%${search}%`
|
||||
conditions.push(
|
||||
or(
|
||||
if (search) {
|
||||
const kw = `%${search}%`
|
||||
const nameCondition = or(
|
||||
like(fileAttachments.originalName, kw),
|
||||
like(fileAttachments.filename, kw)
|
||||
)!
|
||||
)
|
||||
)
|
||||
if (nameCondition) conditions.push(nameCondition)
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(fileAttachments)
|
||||
.where(where)
|
||||
.orderBy(desc(fileAttachments.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
return rows.map(mapRow)
|
||||
} catch (error) {
|
||||
console.error("getFileAttachmentsWithFilters failed:", error)
|
||||
return []
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(fileAttachments)
|
||||
.where(where)
|
||||
.orderBy(desc(fileAttachments.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
return rows.map(mapRow)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取文件统计信息(总数、总大小、按类型分组)
|
||||
*/
|
||||
export async function getFileStats(): Promise<FileStats> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
mimeType: fileAttachments.mimeType,
|
||||
count: count(),
|
||||
size: sql<number>`COALESCE(SUM(${fileAttachments.size}), 0)`,
|
||||
})
|
||||
.from(fileAttachments)
|
||||
.groupBy(fileAttachments.mimeType)
|
||||
export const getFileStats = cache(
|
||||
async (): Promise<FileStats> => {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
mimeType: fileAttachments.mimeType,
|
||||
count: count(),
|
||||
size: sql<number>`COALESCE(SUM(${fileAttachments.size}), 0)`,
|
||||
})
|
||||
.from(fileAttachments)
|
||||
.groupBy(fileAttachments.mimeType)
|
||||
|
||||
const byType = rows.map((r) => ({
|
||||
mimeType: r.mimeType,
|
||||
count: Number(r.count),
|
||||
size: Number(r.size),
|
||||
}))
|
||||
const byType = rows.map((r) => ({
|
||||
mimeType: r.mimeType,
|
||||
count: Number(r.count),
|
||||
size: Number(r.size),
|
||||
}))
|
||||
|
||||
const totalCount = byType.reduce((sum, r) => sum + r.count, 0)
|
||||
const totalSize = byType.reduce((sum, r) => sum + r.size, 0)
|
||||
const totalCount = byType.reduce((sum, r) => sum + r.count, 0)
|
||||
const totalSize = byType.reduce((sum, r) => sum + r.size, 0)
|
||||
|
||||
return { totalCount, totalSize, byType }
|
||||
} catch {
|
||||
return { totalCount: 0, totalSize: 0, byType: [] }
|
||||
}
|
||||
}
|
||||
return { totalCount, totalSize, byType }
|
||||
} catch (error) {
|
||||
console.error("getFileStats failed:", error)
|
||||
return { totalCount: 0, totalSize: 0, byType: [] }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 按 ID 列表批量查询文件(用于批量删除前获取磁盘路径)
|
||||
*/
|
||||
export async function getFileAttachmentsByIds(ids: string[]): Promise<FileAttachment[]> {
|
||||
if (ids.length === 0) return []
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(fileAttachments)
|
||||
.where(inArray(fileAttachments.id, ids))
|
||||
return rows.map(mapRow)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
export const getFileAttachmentsByIds = cache(
|
||||
async (ids: string[]): Promise<FileAttachment[]> => {
|
||||
if (ids.length === 0) return []
|
||||
try {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(fileAttachments)
|
||||
.where(inArray(fileAttachments.id, ids))
|
||||
return rows.map(mapRow)
|
||||
} catch (error) {
|
||||
console.error("getFileAttachmentsByIds failed:", error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, asc, eq, inArray, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { gradeRecords } from "@/shared/db/schema"
|
||||
import {
|
||||
classes,
|
||||
gradeRecords,
|
||||
subjects,
|
||||
} from "@/shared/db/schema"
|
||||
getClassesByGradeId,
|
||||
getClassNameById,
|
||||
} from "@/modules/classes/data-access"
|
||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import type {
|
||||
@@ -54,63 +56,67 @@ export interface GradeTrendParams {
|
||||
currentUserId?: string
|
||||
}
|
||||
|
||||
export async function getGradeTrend(
|
||||
params: GradeTrendParams
|
||||
): Promise<GradeTrendResult | null> {
|
||||
const conditions = [eq(gradeRecords.classId, params.classId)]
|
||||
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
|
||||
if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId))
|
||||
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
|
||||
export const getGradeTrend = cache(
|
||||
async (params: GradeTrendParams): Promise<GradeTrendResult | null> => {
|
||||
const conditions = [eq(gradeRecords.classId, params.classId)]
|
||||
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
|
||||
if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId))
|
||||
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
|
||||
|
||||
if (params.scope.type === "class_members" && params.currentUserId) {
|
||||
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
|
||||
}
|
||||
|
||||
const scopeFilter = buildScopeClassFilter(params.scope)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
record: gradeRecords,
|
||||
className: classes.name,
|
||||
subjectName: subjects.name,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.leftJoin(classes, eq(classes.id, gradeRecords.classId))
|
||||
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(gradeRecords.createdAt))
|
||||
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const points: GradeTrendPoint[] = rows.map((r) => {
|
||||
const score = toNumber(r.record.score)
|
||||
const fullScore = toNumber(r.record.fullScore)
|
||||
return {
|
||||
date: r.record.createdAt.toISOString(),
|
||||
title: r.record.title,
|
||||
score,
|
||||
fullScore,
|
||||
normalizedScore: normalize(score, fullScore),
|
||||
type: r.record.type,
|
||||
if (params.scope.type === "class_members" && params.currentUserId) {
|
||||
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
|
||||
}
|
||||
})
|
||||
|
||||
const avg = points.reduce((acc, p) => acc + p.normalizedScore, 0) / points.length
|
||||
const className = rows[0].className ?? "Class"
|
||||
const subjectName = rows[0].subjectName ?? "All Subjects"
|
||||
const studentLabel = params.studentId
|
||||
? `Student ${params.studentId.slice(-4)}`
|
||||
: "Class Average"
|
||||
const scopeFilter = buildScopeClassFilter(params.scope)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
|
||||
return {
|
||||
label: params.subjectId
|
||||
? `${className} · ${subjectName} · ${studentLabel}`
|
||||
: `${className} · ${studentLabel}`,
|
||||
points,
|
||||
averageScore: Math.round(avg * 100) / 100,
|
||||
const rows = await db
|
||||
.select({
|
||||
record: gradeRecords,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(gradeRecords.createdAt))
|
||||
|
||||
if (rows.length === 0) return null
|
||||
|
||||
// Fetch display names via cross-module interfaces
|
||||
const className = await getClassNameById(params.classId)
|
||||
let subjectName = "All Subjects"
|
||||
if (params.subjectId) {
|
||||
const subjectOptions = await getSubjectOptions()
|
||||
const subject = subjectOptions.find((s) => s.id === params.subjectId)
|
||||
subjectName = subject?.name ?? "Unknown"
|
||||
}
|
||||
|
||||
const points: GradeTrendPoint[] = rows.map((r) => {
|
||||
const score = toNumber(r.record.score)
|
||||
const fullScore = toNumber(r.record.fullScore)
|
||||
return {
|
||||
date: r.record.createdAt.toISOString(),
|
||||
title: r.record.title,
|
||||
score,
|
||||
fullScore,
|
||||
normalizedScore: normalize(score, fullScore),
|
||||
type: r.record.type,
|
||||
}
|
||||
})
|
||||
|
||||
const avg = points.reduce((acc, p) => acc + p.normalizedScore, 0) / points.length
|
||||
const finalClassName = className ?? "Class"
|
||||
const studentLabel = params.studentId
|
||||
? `Student ${params.studentId.slice(-4)}`
|
||||
: "Class Average"
|
||||
|
||||
return {
|
||||
label: params.subjectId
|
||||
? `${finalClassName} · ${subjectName} · ${studentLabel}`
|
||||
: `${finalClassName} · ${studentLabel}`,
|
||||
points,
|
||||
averageScore: Math.round(avg * 100) / 100,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export interface ClassComparisonParams {
|
||||
gradeId: string
|
||||
@@ -119,37 +125,32 @@ export interface ClassComparisonParams {
|
||||
scope: DataScope
|
||||
}
|
||||
|
||||
export async function getClassComparison(
|
||||
params: ClassComparisonParams
|
||||
): Promise<ClassComparisonItem[]> {
|
||||
const classRows = await db
|
||||
.select({ id: classes.id, name: classes.name })
|
||||
.from(classes)
|
||||
.where(eq(classes.gradeId, params.gradeId))
|
||||
export const getClassComparison = cache(
|
||||
async (params: ClassComparisonParams): Promise<ClassComparisonItem[]> => {
|
||||
const classRows = await getClassesByGradeId(params.gradeId)
|
||||
|
||||
if (classRows.length === 0) return []
|
||||
if (classRows.length === 0) return []
|
||||
|
||||
const scope = params.scope
|
||||
const allowedClassIds =
|
||||
scope.type === "class_taught"
|
||||
? classRows.filter((c) => scope.classIds.includes(c.id)).map((c) => c.id)
|
||||
: classRows.map((c) => c.id)
|
||||
const scope = params.scope
|
||||
const scopeClassIdSet =
|
||||
scope.type === "class_taught" ? new Set(scope.classIds) : null
|
||||
const allowedClassRows = scopeClassIdSet
|
||||
? classRows.filter((c) => scopeClassIdSet.has(c.id))
|
||||
: classRows
|
||||
|
||||
if (allowedClassIds.length === 0) return []
|
||||
if (allowedClassRows.length === 0) return []
|
||||
|
||||
const result: ClassComparisonItem[] = []
|
||||
|
||||
for (const cls of classRows) {
|
||||
if (!allowedClassIds.includes(cls.id)) continue
|
||||
const allowedClassIds = allowedClassRows.map((c) => c.id)
|
||||
|
||||
const conditions = [
|
||||
eq(gradeRecords.classId, cls.id),
|
||||
inArray(gradeRecords.classId, allowedClassIds),
|
||||
eq(gradeRecords.subjectId, params.subjectId),
|
||||
]
|
||||
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
|
||||
|
||||
const rows = await db
|
||||
const allRows = await db
|
||||
.select({
|
||||
classId: gradeRecords.classId,
|
||||
score: gradeRecords.score,
|
||||
fullScore: gradeRecords.fullScore,
|
||||
studentId: gradeRecords.studentId,
|
||||
@@ -157,35 +158,64 @@ export async function getClassComparison(
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
|
||||
if (rows.length === 0) {
|
||||
result.push({
|
||||
classId: cls.id, className: cls.name, averageScore: 0, medianScore: 0,
|
||||
passRate: 0, excellentRate: 0, count: 0, studentCount: 0,
|
||||
})
|
||||
continue
|
||||
const byClass = new Map<string, typeof allRows>()
|
||||
for (const r of allRows) {
|
||||
const existing = byClass.get(r.classId)
|
||||
if (existing) {
|
||||
existing.push(r)
|
||||
} else {
|
||||
byClass.set(r.classId, [r])
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = rows.map((r) => normalize(toNumber(r.score), toNumber(r.fullScore)))
|
||||
const sorted = [...normalized].sort((a, b) => a - b)
|
||||
const mid = Math.floor(sorted.length / 2)
|
||||
const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
|
||||
const avg = normalized.reduce((a, b) => a + b, 0) / normalized.length
|
||||
const uniqueStudents = new Set(rows.map((r) => r.studentId)).size
|
||||
const result: ClassComparisonItem[] = allowedClassRows.map((cls) => {
|
||||
const rows = byClass.get(cls.id) ?? []
|
||||
if (rows.length === 0) {
|
||||
return {
|
||||
classId: cls.id,
|
||||
className: cls.name,
|
||||
averageScore: 0,
|
||||
medianScore: 0,
|
||||
passRate: 0,
|
||||
excellentRate: 0,
|
||||
count: 0,
|
||||
studentCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
classId: cls.id,
|
||||
className: cls.name,
|
||||
averageScore: Math.round(avg * 100) / 100,
|
||||
medianScore: Math.round(median * 100) / 100,
|
||||
passRate: Math.round((normalized.filter((s) => s >= 60).length / normalized.length) * 10000) / 100,
|
||||
excellentRate: Math.round((normalized.filter((s) => s >= 85).length / normalized.length) * 10000) / 100,
|
||||
count: normalized.length,
|
||||
studentCount: uniqueStudents,
|
||||
const normalized = rows.map((r) =>
|
||||
normalize(toNumber(r.score), toNumber(r.fullScore))
|
||||
)
|
||||
const sorted = [...normalized].sort((a, b) => a - b)
|
||||
const mid = Math.floor(sorted.length / 2)
|
||||
const median =
|
||||
sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
|
||||
const avg = normalized.reduce((a, b) => a + b, 0) / normalized.length
|
||||
const uniqueStudents = new Set(rows.map((r) => r.studentId)).size
|
||||
|
||||
const { passCount, excellentCount } = normalized.reduce(
|
||||
(acc, s) => ({
|
||||
passCount: acc.passCount + (s >= 60 ? 1 : 0),
|
||||
excellentCount: acc.excellentCount + (s >= 85 ? 1 : 0),
|
||||
}),
|
||||
{ passCount: 0, excellentCount: 0 }
|
||||
)
|
||||
|
||||
return {
|
||||
classId: cls.id,
|
||||
className: cls.name,
|
||||
averageScore: Math.round(avg * 100) / 100,
|
||||
medianScore: Math.round(median * 100) / 100,
|
||||
passRate: Math.round((passCount / normalized.length) * 10000) / 100,
|
||||
excellentRate: Math.round((excellentCount / normalized.length) * 10000) / 100,
|
||||
count: normalized.length,
|
||||
studentCount: uniqueStudents,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
export interface SubjectComparisonParams {
|
||||
classId: string
|
||||
@@ -193,56 +223,71 @@ export interface SubjectComparisonParams {
|
||||
scope: DataScope
|
||||
}
|
||||
|
||||
export async function getSubjectComparison(
|
||||
params: SubjectComparisonParams
|
||||
): Promise<SubjectComparisonItem[]> {
|
||||
const scopeFilter = buildScopeClassFilter(params.scope)
|
||||
const conditions = [eq(gradeRecords.classId, params.classId)]
|
||||
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
export const getSubjectComparison = cache(
|
||||
async (params: SubjectComparisonParams): Promise<SubjectComparisonItem[]> => {
|
||||
const scopeFilter = buildScopeClassFilter(params.scope)
|
||||
const conditions = [eq(gradeRecords.classId, params.classId)]
|
||||
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
subjectId: gradeRecords.subjectId,
|
||||
subjectName: subjects.name,
|
||||
score: gradeRecords.score,
|
||||
fullScore: gradeRecords.fullScore,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
|
||||
.where(and(...conditions))
|
||||
const rows = await db
|
||||
.select({
|
||||
subjectId: gradeRecords.subjectId,
|
||||
score: gradeRecords.score,
|
||||
fullScore: gradeRecords.fullScore,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
|
||||
const bySubject = new Map<string, { name: string; scores: number[] }>()
|
||||
if (rows.length === 0) return []
|
||||
|
||||
for (const r of rows) {
|
||||
const sid = r.subjectId
|
||||
if (!sid) continue
|
||||
const entry = bySubject.get(sid) ?? { name: r.subjectName ?? "Unknown", scores: [] }
|
||||
entry.scores.push(normalize(toNumber(r.score), toNumber(r.fullScore)))
|
||||
bySubject.set(sid, entry)
|
||||
// Fetch subject names via cross-module interface
|
||||
const subjectIds = Array.from(new Set(rows.map((r) => r.subjectId).filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
const subjectOptions = await getSubjectOptions()
|
||||
const subjectNameById = new Map<string, string>()
|
||||
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
|
||||
|
||||
const bySubject = new Map<string, { name: string; scores: number[] }>()
|
||||
|
||||
for (const r of rows) {
|
||||
const sid = r.subjectId
|
||||
if (!sid) continue
|
||||
const entry = bySubject.get(sid) ?? { name: subjectNameById.get(sid) ?? "Unknown", scores: [] }
|
||||
entry.scores.push(normalize(toNumber(r.score), toNumber(r.fullScore)))
|
||||
bySubject.set(sid, entry)
|
||||
}
|
||||
|
||||
const result: SubjectComparisonItem[] = []
|
||||
for (const [subjectId, entry] of bySubject.entries()) {
|
||||
if (entry.scores.length === 0) continue
|
||||
const sorted = [...entry.scores].sort((a, b) => a - b)
|
||||
const mid = Math.floor(sorted.length / 2)
|
||||
const median =
|
||||
sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
|
||||
const avg = entry.scores.reduce((a, b) => a + b, 0) / entry.scores.length
|
||||
|
||||
const { passCount, excellentCount } = entry.scores.reduce(
|
||||
(acc, s) => ({
|
||||
passCount: acc.passCount + (s >= 60 ? 1 : 0),
|
||||
excellentCount: acc.excellentCount + (s >= 85 ? 1 : 0),
|
||||
}),
|
||||
{ passCount: 0, excellentCount: 0 }
|
||||
)
|
||||
|
||||
result.push({
|
||||
subjectId,
|
||||
subjectName: entry.name,
|
||||
averageScore: Math.round(avg * 100) / 100,
|
||||
medianScore: Math.round(median * 100) / 100,
|
||||
passRate: Math.round((passCount / entry.scores.length) * 10000) / 100,
|
||||
excellentRate: Math.round((excellentCount / entry.scores.length) * 10000) / 100,
|
||||
count: entry.scores.length,
|
||||
})
|
||||
}
|
||||
|
||||
return result.sort((a, b) => b.averageScore - a.averageScore)
|
||||
}
|
||||
|
||||
const result: SubjectComparisonItem[] = []
|
||||
for (const [subjectId, entry] of bySubject.entries()) {
|
||||
if (entry.scores.length === 0) continue
|
||||
const sorted = [...entry.scores].sort((a, b) => a - b)
|
||||
const mid = Math.floor(sorted.length / 2)
|
||||
const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
|
||||
const avg = entry.scores.reduce((a, b) => a + b, 0) / entry.scores.length
|
||||
|
||||
result.push({
|
||||
subjectId,
|
||||
subjectName: entry.name,
|
||||
averageScore: Math.round(avg * 100) / 100,
|
||||
medianScore: Math.round(median * 100) / 100,
|
||||
passRate: Math.round((entry.scores.filter((s) => s >= 60).length / entry.scores.length) * 10000) / 100,
|
||||
excellentRate: Math.round((entry.scores.filter((s) => s >= 85).length / entry.scores.length) * 10000) / 100,
|
||||
count: entry.scores.length,
|
||||
})
|
||||
}
|
||||
|
||||
return result.sort((a, b) => b.averageScore - a.averageScore)
|
||||
}
|
||||
)
|
||||
|
||||
export interface GradeDistributionParams {
|
||||
classId: string
|
||||
@@ -252,42 +297,42 @@ export interface GradeDistributionParams {
|
||||
currentUserId?: string
|
||||
}
|
||||
|
||||
export async function getGradeDistribution(
|
||||
params: GradeDistributionParams
|
||||
): Promise<GradeDistributionResult> {
|
||||
const conditions = [eq(gradeRecords.classId, params.classId)]
|
||||
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
|
||||
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
|
||||
export const getGradeDistribution = cache(
|
||||
async (params: GradeDistributionParams): Promise<GradeDistributionResult> => {
|
||||
const conditions = [eq(gradeRecords.classId, params.classId)]
|
||||
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
|
||||
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
|
||||
|
||||
if (params.scope.type === "class_members" && params.currentUserId) {
|
||||
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
|
||||
if (params.scope.type === "class_members" && params.currentUserId) {
|
||||
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
|
||||
}
|
||||
|
||||
const scopeFilter = buildScopeClassFilter(params.scope)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
|
||||
const rows = await db
|
||||
.select({ score: gradeRecords.score, fullScore: gradeRecords.fullScore })
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
|
||||
const buckets: GradeDistributionBucket[] = [
|
||||
{ label: "90-100", min: 90, max: 100, count: 0 },
|
||||
{ label: "80-89", min: 80, max: 89, count: 0 },
|
||||
{ label: "70-79", min: 70, max: 79, count: 0 },
|
||||
{ label: "60-69", min: 60, max: 69, count: 0 },
|
||||
{ label: "<60", min: 0, max: 59, count: 0 },
|
||||
]
|
||||
|
||||
for (const r of rows) {
|
||||
const normalized = normalize(toNumber(r.score), toNumber(r.fullScore))
|
||||
const rounded = Math.round(normalized)
|
||||
if (rounded >= 90) buckets[0].count++
|
||||
else if (rounded >= 80) buckets[1].count++
|
||||
else if (rounded >= 70) buckets[2].count++
|
||||
else if (rounded >= 60) buckets[3].count++
|
||||
else buckets[4].count++
|
||||
}
|
||||
|
||||
return { buckets, totalCount: rows.length }
|
||||
}
|
||||
|
||||
const scopeFilter = buildScopeClassFilter(params.scope)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
|
||||
const rows = await db
|
||||
.select({ score: gradeRecords.score, fullScore: gradeRecords.fullScore })
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
|
||||
const buckets: GradeDistributionBucket[] = [
|
||||
{ label: "90-100", min: 90, max: 100, count: 0 },
|
||||
{ label: "80-89", min: 80, max: 89, count: 0 },
|
||||
{ label: "70-79", min: 70, max: 79, count: 0 },
|
||||
{ label: "60-69", min: 60, max: 69, count: 0 },
|
||||
{ label: "<60", min: 0, max: 59, count: 0 },
|
||||
]
|
||||
|
||||
for (const r of rows) {
|
||||
const normalized = normalize(toNumber(r.score), toNumber(r.fullScore))
|
||||
const rounded = Math.round(normalized)
|
||||
if (rounded >= 90) buckets[0].count++
|
||||
else if (rounded >= 80) buckets[1].count++
|
||||
else if (rounded >= 70) buckets[2].count++
|
||||
else if (rounded >= 60) buckets[3].count++
|
||||
else buckets[4].count++
|
||||
}
|
||||
|
||||
return { buckets, totalCount: rows.length }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, asc, eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classEnrollments,
|
||||
gradeRecords,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
import { gradeRecords } from "@/shared/db/schema"
|
||||
import { getStudentActiveClassId } from "@/modules/classes/data-access"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
|
||||
import type {
|
||||
RankingTrendPoint,
|
||||
@@ -29,93 +28,92 @@ const normalize = (score: number, fullScore: number): number => {
|
||||
* Each point represents one assessment (grouped by title), with the
|
||||
* student's normalized score, rank, and total participants.
|
||||
*/
|
||||
export async function getRankingTrend(
|
||||
studentId: string,
|
||||
subjectId?: string,
|
||||
semester?: "1" | "2"
|
||||
): Promise<RankingTrendResult | null> {
|
||||
const [student] = await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(eq(users.id, studentId))
|
||||
.limit(1)
|
||||
if (!student) return null
|
||||
export const getRankingTrend = cache(
|
||||
async (
|
||||
studentId: string,
|
||||
subjectId?: string,
|
||||
semester?: "1" | "2"
|
||||
): Promise<RankingTrendResult | null> => {
|
||||
const studentNameMap = await getUserNamesByIds([studentId])
|
||||
const studentInfo = studentNameMap.get(studentId)
|
||||
if (!studentInfo) return null
|
||||
const studentName = studentInfo.name ?? "Unknown"
|
||||
|
||||
const [enrollment] = await db
|
||||
.select({ classId: classEnrollments.classId })
|
||||
.from(classEnrollments)
|
||||
.where(
|
||||
and(
|
||||
eq(classEnrollments.studentId, studentId),
|
||||
eq(classEnrollments.status, "active")
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
const classId = await getStudentActiveClassId(studentId)
|
||||
|
||||
if (!classId) {
|
||||
return {
|
||||
studentId,
|
||||
studentName,
|
||||
points: [],
|
||||
}
|
||||
}
|
||||
|
||||
const conditions = [eq(gradeRecords.classId, classId)]
|
||||
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
|
||||
if (semester) conditions.push(eq(gradeRecords.semester, semester))
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
title: gradeRecords.title,
|
||||
createdAt: gradeRecords.createdAt,
|
||||
studentId: gradeRecords.studentId,
|
||||
score: gradeRecords.score,
|
||||
fullScore: gradeRecords.fullScore,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(gradeRecords.createdAt))
|
||||
|
||||
const byTitle = new Map<
|
||||
string,
|
||||
{
|
||||
date: Date
|
||||
entries: Array<{ studentId: string; normalized: number }>
|
||||
}
|
||||
>()
|
||||
|
||||
for (const r of rows) {
|
||||
const entry = byTitle.get(r.title) ?? { date: r.createdAt, entries: [] }
|
||||
entry.entries.push({
|
||||
studentId: r.studentId,
|
||||
normalized: normalize(toNumber(r.score), toNumber(r.fullScore)),
|
||||
})
|
||||
byTitle.set(r.title, entry)
|
||||
}
|
||||
|
||||
const points: RankingTrendPoint[] = []
|
||||
for (const [title, entry] of byTitle.entries()) {
|
||||
if (entry.entries.length === 0) continue
|
||||
const sorted = [...entry.entries].sort((a, b) => b.normalized - a.normalized)
|
||||
// Single traversal: find rank and student entry together
|
||||
let rank = 0
|
||||
let studentEntry: { studentId: string; normalized: number } | null = null
|
||||
for (let i = 0; i < sorted.length; i += 1) {
|
||||
const e = sorted[i]
|
||||
if (e.studentId === studentId) {
|
||||
rank = i + 1
|
||||
studentEntry = e
|
||||
break
|
||||
}
|
||||
}
|
||||
if (rank <= 0 || !studentEntry) continue
|
||||
|
||||
points.push({
|
||||
title,
|
||||
date: entry.date.toISOString(),
|
||||
score: studentEntry.normalized,
|
||||
rank,
|
||||
totalStudents: sorted.length,
|
||||
})
|
||||
}
|
||||
|
||||
points.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
|
||||
if (!enrollment) {
|
||||
return {
|
||||
studentId,
|
||||
studentName: student.name ?? "Unknown",
|
||||
points: [],
|
||||
studentName,
|
||||
points,
|
||||
}
|
||||
}
|
||||
|
||||
const conditions = [eq(gradeRecords.classId, enrollment.classId)]
|
||||
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
|
||||
if (semester) conditions.push(eq(gradeRecords.semester, semester))
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
title: gradeRecords.title,
|
||||
createdAt: gradeRecords.createdAt,
|
||||
studentId: gradeRecords.studentId,
|
||||
score: gradeRecords.score,
|
||||
fullScore: gradeRecords.fullScore,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(gradeRecords.createdAt))
|
||||
|
||||
const byTitle = new Map<
|
||||
string,
|
||||
{
|
||||
date: Date
|
||||
entries: Array<{ studentId: string; normalized: number }>
|
||||
}
|
||||
>()
|
||||
|
||||
for (const r of rows) {
|
||||
const entry = byTitle.get(r.title) ?? { date: r.createdAt, entries: [] }
|
||||
entry.entries.push({
|
||||
studentId: r.studentId,
|
||||
normalized: normalize(toNumber(r.score), toNumber(r.fullScore)),
|
||||
})
|
||||
byTitle.set(r.title, entry)
|
||||
}
|
||||
|
||||
const points: RankingTrendPoint[] = []
|
||||
for (const [title, entry] of byTitle.entries()) {
|
||||
if (entry.entries.length === 0) continue
|
||||
const sorted = [...entry.entries].sort((a, b) => b.normalized - a.normalized)
|
||||
const rank = sorted.findIndex((e) => e.studentId === studentId) + 1
|
||||
if (rank <= 0) continue
|
||||
const studentEntry = sorted.find((e) => e.studentId === studentId)
|
||||
if (!studentEntry) continue
|
||||
|
||||
points.push({
|
||||
title,
|
||||
date: entry.date.toISOString(),
|
||||
score: studentEntry.normalized,
|
||||
rank,
|
||||
totalStudents: sorted.length,
|
||||
})
|
||||
}
|
||||
|
||||
points.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
|
||||
|
||||
return {
|
||||
studentId,
|
||||
studentName: student.name ?? "Unknown",
|
||||
points,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import "server-only"
|
||||
|
||||
import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm"
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, count, desc, eq, inArray, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { gradeRecords } from "@/shared/db/schema"
|
||||
import {
|
||||
classes,
|
||||
classEnrollments,
|
||||
gradeRecords,
|
||||
subjects,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
getActiveStudentIdsByClassId,
|
||||
getClassExists,
|
||||
getClassNameById,
|
||||
getClassNamesByIds,
|
||||
} from "@/modules/classes/data-access"
|
||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import type {
|
||||
@@ -70,82 +74,83 @@ const buildScopeClassFilter = (scope: DataScope) => {
|
||||
return sql`1=0`
|
||||
}
|
||||
|
||||
export async function getGradeRecords(
|
||||
params: GradeQueryParams & { scope: DataScope; currentUserId?: string }
|
||||
): Promise<GradeRecordListItem[]> {
|
||||
const conditions = []
|
||||
export const getGradeRecords = cache(
|
||||
async (
|
||||
params: GradeQueryParams & { scope: DataScope; currentUserId?: string }
|
||||
): Promise<GradeRecordListItem[]> => {
|
||||
const conditions = []
|
||||
|
||||
const scopeFilter = buildScopeClassFilter(params.scope)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
const scopeFilter = buildScopeClassFilter(params.scope)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
|
||||
if (params.scope.type === "class_members" && params.currentUserId) {
|
||||
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
|
||||
}
|
||||
|
||||
if (params.classId) conditions.push(eq(gradeRecords.classId, params.classId))
|
||||
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
|
||||
if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId))
|
||||
if (params.type) conditions.push(eq(gradeRecords.type, params.type))
|
||||
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
|
||||
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
record: gradeRecords,
|
||||
studentName: users.name,
|
||||
className: classes.name,
|
||||
subjectName: subjects.name,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.leftJoin(users, eq(users.id, gradeRecords.studentId))
|
||||
.leftJoin(classes, eq(classes.id, gradeRecords.classId))
|
||||
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(desc(gradeRecords.createdAt))
|
||||
|
||||
const recorderIds = Array.from(new Set(rows.map((r) => r.record.recordedBy)))
|
||||
const recorderMap = new Map<string, string>()
|
||||
if (recorderIds.length > 0) {
|
||||
const recorders = await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, recorderIds))
|
||||
for (const r of recorders) {
|
||||
recorderMap.set(r.id, r.name ?? "Unknown")
|
||||
if (params.scope.type === "class_members" && params.currentUserId) {
|
||||
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
|
||||
}
|
||||
|
||||
if (params.classId) conditions.push(eq(gradeRecords.classId, params.classId))
|
||||
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
|
||||
if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId))
|
||||
if (params.type) conditions.push(eq(gradeRecords.type, params.type))
|
||||
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
|
||||
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
record: gradeRecords,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(desc(gradeRecords.createdAt))
|
||||
|
||||
if (rows.length === 0) return []
|
||||
|
||||
// Batch fetch display names via cross-module interfaces
|
||||
const studentIds = Array.from(new Set(rows.map((r) => r.record.studentId)))
|
||||
const classIds = Array.from(new Set(rows.map((r) => r.record.classId).filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
const subjectIds = Array.from(new Set(rows.map((r) => r.record.subjectId).filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
const recorderIds = Array.from(new Set(rows.map((r) => r.record.recordedBy)))
|
||||
|
||||
const [studentNameMap, classNameMap, subjectOptions, recorderNameMap] = await Promise.all([
|
||||
getUserNamesByIds(studentIds),
|
||||
getClassNamesByIds(classIds),
|
||||
getSubjectOptions(),
|
||||
getUserNamesByIds(recorderIds),
|
||||
])
|
||||
|
||||
const subjectNameById = new Map<string, string>()
|
||||
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.record.id,
|
||||
studentId: r.record.studentId,
|
||||
studentName: studentNameMap.get(r.record.studentId)?.name ?? "Unknown",
|
||||
classId: r.record.classId,
|
||||
className: r.record.classId ? classNameMap.get(r.record.classId) ?? "Unknown" : "Unknown",
|
||||
subjectId: r.record.subjectId,
|
||||
subjectName: r.record.subjectId ? subjectNameById.get(r.record.subjectId) ?? "Unknown" : "Unknown",
|
||||
examId: r.record.examId ?? null,
|
||||
title: r.record.title,
|
||||
score: toNumber(r.record.score),
|
||||
fullScore: toNumber(r.record.fullScore),
|
||||
type: r.record.type,
|
||||
semester: r.record.semester,
|
||||
recordedBy: r.record.recordedBy,
|
||||
recorderName: recorderNameMap.get(r.record.recordedBy)?.name ?? "Unknown",
|
||||
remark: r.record.remark ?? null,
|
||||
createdAt: r.record.createdAt.toISOString(),
|
||||
}))
|
||||
}
|
||||
)
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.record.id,
|
||||
studentId: r.record.studentId,
|
||||
studentName: r.studentName ?? "Unknown",
|
||||
classId: r.record.classId,
|
||||
className: r.className ?? "Unknown",
|
||||
subjectId: r.record.subjectId,
|
||||
subjectName: r.subjectName ?? "Unknown",
|
||||
examId: r.record.examId ?? null,
|
||||
title: r.record.title,
|
||||
score: toNumber(r.record.score),
|
||||
fullScore: toNumber(r.record.fullScore),
|
||||
type: r.record.type,
|
||||
semester: r.record.semester,
|
||||
recordedBy: r.record.recordedBy,
|
||||
recorderName: recorderMap.get(r.record.recordedBy) ?? "Unknown",
|
||||
remark: r.record.remark ?? null,
|
||||
createdAt: r.record.createdAt.toISOString(),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getGradeRecordById(id: string): Promise<GradeRecord | null> {
|
||||
export const getGradeRecordById = cache(async (id: string): Promise<GradeRecord | null> => {
|
||||
const [row] = await db.select().from(gradeRecords).where(eq(gradeRecords.id, id)).limit(1)
|
||||
return row ? serializeRecord(row) : null
|
||||
}
|
||||
})
|
||||
|
||||
export async function createGradeRecord(
|
||||
data: CreateGradeRecordInput,
|
||||
recordedBy: string
|
||||
): Promise<string> {
|
||||
const { createId } = await import("@paralleldrive/cuid2")
|
||||
const id = createId()
|
||||
await db.insert(gradeRecords).values({
|
||||
id,
|
||||
@@ -169,7 +174,6 @@ export async function batchCreateGradeRecords(
|
||||
data: BatchCreateGradeRecordInput,
|
||||
recordedBy: string
|
||||
): Promise<number> {
|
||||
const { createId } = await import("@paralleldrive/cuid2")
|
||||
const rows = data.records.map((r) => ({
|
||||
id: createId(),
|
||||
studentId: r.studentId,
|
||||
@@ -211,94 +215,106 @@ export async function deleteGradeRecord(id: string): Promise<void> {
|
||||
await db.delete(gradeRecords).where(eq(gradeRecords.id, id))
|
||||
}
|
||||
|
||||
export async function getClassGradeStats(
|
||||
classId: string,
|
||||
subjectId?: string,
|
||||
examId?: string
|
||||
): Promise<GradeStats | null> {
|
||||
const conditions = [eq(gradeRecords.classId, classId)]
|
||||
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
|
||||
if (examId) conditions.push(eq(gradeRecords.examId, examId))
|
||||
export const getClassGradeStats = cache(
|
||||
async (
|
||||
classId: string,
|
||||
subjectId?: string,
|
||||
examId?: string
|
||||
): Promise<GradeStats | null> => {
|
||||
const conditions = [eq(gradeRecords.classId, classId)]
|
||||
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
|
||||
if (examId) conditions.push(eq(gradeRecords.examId, examId))
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
score: gradeRecords.score,
|
||||
fullScore: gradeRecords.fullScore,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
const rows = await db
|
||||
.select({
|
||||
score: gradeRecords.score,
|
||||
fullScore: gradeRecords.fullScore,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
|
||||
if (rows.length === 0) return null
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const scores = rows.map((r) => toNumber(r.score))
|
||||
const fullScores = rows.map((r) => toNumber(r.fullScore))
|
||||
const countN = scores.length
|
||||
const sum = scores.reduce((a, b) => a + b, 0)
|
||||
const average = sum / countN
|
||||
const sorted = [...scores].sort((a, b) => a - b)
|
||||
const mid = Math.floor(countN / 2)
|
||||
const median = countN % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
|
||||
const max = sorted[countN - 1]
|
||||
const min = sorted[0]
|
||||
const variance = scores.reduce((acc, s) => acc + Math.pow(s - average, 2), 0) / countN
|
||||
const stdDev = Math.sqrt(variance)
|
||||
const scores = rows.map((r) => toNumber(r.score))
|
||||
const fullScores = rows.map((r) => toNumber(r.fullScore))
|
||||
const countN = scores.length
|
||||
const sum = scores.reduce((a, b) => a + b, 0)
|
||||
const average = sum / countN
|
||||
const sorted = [...scores].sort((a, b) => a - b)
|
||||
const mid = Math.floor(countN / 2)
|
||||
const median = countN % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
|
||||
const max = sorted[countN - 1]
|
||||
const min = sorted[0]
|
||||
const variance = scores.reduce((acc, s) => acc + Math.pow(s - average, 2), 0) / countN
|
||||
const stdDev = Math.sqrt(variance)
|
||||
|
||||
let passCount = 0
|
||||
let excellentCount = 0
|
||||
for (let i = 0; i < countN; i++) {
|
||||
const ratio = scores[i] / fullScores[i]
|
||||
if (ratio >= 0.6) passCount++
|
||||
if (ratio >= 0.85) excellentCount++
|
||||
let passCount = 0
|
||||
let excellentCount = 0
|
||||
for (let i = 0; i < countN; i++) {
|
||||
if (fullScores[i] <= 0) continue
|
||||
const ratio = scores[i] / fullScores[i]
|
||||
if (ratio >= 0.6) passCount++
|
||||
if (ratio >= 0.85) excellentCount++
|
||||
}
|
||||
|
||||
return {
|
||||
average: Math.round(average * 100) / 100,
|
||||
median: Math.round(median * 100) / 100,
|
||||
max,
|
||||
min,
|
||||
stdDev: Math.round(stdDev * 100) / 100,
|
||||
passRate: Math.round((passCount / countN) * 10000) / 100,
|
||||
excellentRate: Math.round((excellentCount / countN) * 10000) / 100,
|
||||
count: countN,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
average: Math.round(average * 100) / 100,
|
||||
median: Math.round(median * 100) / 100,
|
||||
max,
|
||||
min,
|
||||
stdDev: Math.round(stdDev * 100) / 100,
|
||||
passRate: Math.round((passCount / countN) * 10000) / 100,
|
||||
excellentRate: Math.round((excellentCount / countN) * 10000) / 100,
|
||||
count: countN,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export async function getStudentGradeSummary(
|
||||
studentId: string
|
||||
): Promise<StudentGradeSummary | null> {
|
||||
const [student] = await db.select({ name: users.name }).from(users).where(eq(users.id, studentId)).limit(1)
|
||||
if (!student) return null
|
||||
const studentNameMap = await getUserNamesByIds([studentId])
|
||||
const studentName = studentNameMap.get(studentId)?.name ?? null
|
||||
if (!studentName && !studentNameMap.has(studentId)) return null
|
||||
|
||||
const records = await db
|
||||
.select({
|
||||
record: gradeRecords,
|
||||
className: classes.name,
|
||||
subjectName: subjects.name,
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.leftJoin(classes, eq(classes.id, gradeRecords.classId))
|
||||
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
|
||||
.where(eq(gradeRecords.studentId, studentId))
|
||||
.orderBy(desc(gradeRecords.createdAt))
|
||||
|
||||
if (records.length === 0) {
|
||||
return {
|
||||
studentId,
|
||||
studentName: student.name ?? "Unknown",
|
||||
studentName: studentName ?? "Unknown",
|
||||
records: [],
|
||||
averageScore: 0,
|
||||
rank: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Batch fetch display names via cross-module interfaces
|
||||
const classIds = Array.from(new Set(records.map((r) => r.record.classId).filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
const subjectIds = Array.from(new Set(records.map((r) => r.record.subjectId).filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
|
||||
const [classNameMap, subjectOptions] = await Promise.all([
|
||||
getClassNamesByIds(classIds),
|
||||
getSubjectOptions(),
|
||||
])
|
||||
|
||||
const subjectNameById = new Map<string, string>()
|
||||
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
|
||||
|
||||
const listItems: GradeRecordListItem[] = records.map((r) => ({
|
||||
id: r.record.id,
|
||||
studentId: r.record.studentId,
|
||||
studentName: student.name ?? "Unknown",
|
||||
studentName: studentName ?? "Unknown",
|
||||
classId: r.record.classId,
|
||||
className: r.className ?? "Unknown",
|
||||
className: r.record.classId ? classNameMap.get(r.record.classId) ?? "Unknown" : "Unknown",
|
||||
subjectId: r.record.subjectId,
|
||||
subjectName: r.subjectName ?? "Unknown",
|
||||
subjectName: r.record.subjectId ? subjectNameById.get(r.record.subjectId) ?? "Unknown" : "Unknown",
|
||||
examId: r.record.examId ?? null,
|
||||
title: r.record.title,
|
||||
score: toNumber(r.record.score),
|
||||
@@ -315,63 +331,67 @@ export async function getStudentGradeSummary(
|
||||
|
||||
return {
|
||||
studentId,
|
||||
studentName: student.name ?? "Unknown",
|
||||
studentName: studentName ?? "Unknown",
|
||||
records: listItems,
|
||||
averageScore: Math.round(avg * 100) / 100,
|
||||
rank: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getClassRanking(
|
||||
classId: string,
|
||||
subjectId?: string,
|
||||
examId?: string
|
||||
): Promise<ClassRankingItem[]> {
|
||||
const conditions = [eq(gradeRecords.classId, classId)]
|
||||
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
|
||||
if (examId) conditions.push(eq(gradeRecords.examId, examId))
|
||||
export const getClassRanking = cache(
|
||||
async (
|
||||
classId: string,
|
||||
subjectId?: string,
|
||||
examId?: string
|
||||
): Promise<ClassRankingItem[]> => {
|
||||
const conditions = [eq(gradeRecords.classId, classId)]
|
||||
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
|
||||
if (examId) conditions.push(eq(gradeRecords.examId, examId))
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
studentId: gradeRecords.studentId,
|
||||
studentName: users.name,
|
||||
avgScore: sql<number>`AVG(${gradeRecords.score})`,
|
||||
recordCount: count(gradeRecords.id),
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.leftJoin(users, eq(users.id, gradeRecords.studentId))
|
||||
.where(and(...conditions))
|
||||
.groupBy(gradeRecords.studentId, users.name)
|
||||
.orderBy(desc(sql`AVG(${gradeRecords.score})`))
|
||||
const rows = await db
|
||||
.select({
|
||||
studentId: gradeRecords.studentId,
|
||||
avgScore: sql<number>`AVG(${gradeRecords.score})`,
|
||||
recordCount: count(gradeRecords.id),
|
||||
})
|
||||
.from(gradeRecords)
|
||||
.where(and(...conditions))
|
||||
.groupBy(gradeRecords.studentId)
|
||||
.orderBy(desc(sql`AVG(${gradeRecords.score})`))
|
||||
|
||||
return rows.map((r, idx) => ({
|
||||
studentId: r.studentId,
|
||||
studentName: r.studentName ?? "Unknown",
|
||||
averageScore: Math.round(toNumber(r.avgScore) * 100) / 100,
|
||||
rank: idx + 1,
|
||||
recordCount: toNumber(r.recordCount),
|
||||
}))
|
||||
}
|
||||
if (rows.length === 0) return []
|
||||
|
||||
const studentIds = Array.from(new Set(rows.map((r) => r.studentId)))
|
||||
const studentNameMap = await getUserNamesByIds(studentIds)
|
||||
|
||||
return rows.map((r, idx) => ({
|
||||
studentId: r.studentId,
|
||||
studentName: studentNameMap.get(r.studentId)?.name ?? "Unknown",
|
||||
averageScore: Math.round(toNumber(r.avgScore) * 100) / 100,
|
||||
rank: idx + 1,
|
||||
recordCount: toNumber(r.recordCount),
|
||||
}))
|
||||
}
|
||||
)
|
||||
|
||||
export async function getClassStudentsForEntry(classId: string): Promise<
|
||||
Array<{ id: string; name: string; email: string }>
|
||||
> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
})
|
||||
.from(classEnrollments)
|
||||
.innerJoin(users, eq(users.id, classEnrollments.studentId))
|
||||
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
|
||||
.orderBy(asc(users.name))
|
||||
const studentIds = await getActiveStudentIdsByClassId(classId)
|
||||
if (studentIds.length === 0) return []
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name ?? "Unknown",
|
||||
email: r.email,
|
||||
}))
|
||||
const studentNameMap = await getUserNamesByIds(studentIds)
|
||||
|
||||
return studentIds
|
||||
.map((id) => {
|
||||
const info = studentNameMap.get(id)
|
||||
return {
|
||||
id,
|
||||
name: info?.name ?? "Unknown",
|
||||
email: info?.email ?? "",
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
export async function getClassGradeStatsWithMeta(
|
||||
@@ -379,18 +399,15 @@ export async function getClassGradeStatsWithMeta(
|
||||
subjectId?: string,
|
||||
examId?: string
|
||||
): Promise<ClassGradeStats | null> {
|
||||
const [classRow] = await db
|
||||
.select({ id: classes.id, name: classes.name })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, classId))
|
||||
.limit(1)
|
||||
if (!classRow) return null
|
||||
const classExists = await getClassExists(classId)
|
||||
if (!classExists) return null
|
||||
|
||||
const className = await getClassNameById(classId)
|
||||
const stats = await getClassGradeStats(classId, subjectId, examId)
|
||||
if (!stats) {
|
||||
return {
|
||||
classId,
|
||||
className: classRow.name,
|
||||
className: className ?? "Unknown",
|
||||
stats: {
|
||||
average: 0,
|
||||
median: 0,
|
||||
@@ -405,15 +422,12 @@ export async function getClassGradeStatsWithMeta(
|
||||
}
|
||||
}
|
||||
|
||||
const [studentCountRow] = await db
|
||||
.select({ c: count() })
|
||||
.from(classEnrollments)
|
||||
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
|
||||
const activeStudentIds = await getActiveStudentIdsByClassId(classId)
|
||||
|
||||
return {
|
||||
classId,
|
||||
className: classRow.name,
|
||||
className: className ?? "Unknown",
|
||||
stats,
|
||||
studentCount: toNumber(studentCountRow?.c ?? 0),
|
||||
studentCount: activeStudentIds.length,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import "server-only"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
gradeRecords,
|
||||
subjects,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
import { getClassNameById } from "@/modules/classes/data-access"
|
||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
import { exportToExcel } from "@/shared/lib/excel"
|
||||
|
||||
@@ -113,45 +107,43 @@ export async function exportClassGradeReportToExcel(params: {
|
||||
classId: string
|
||||
scope: DataScope
|
||||
}): Promise<Buffer> {
|
||||
const [classRow] = await db
|
||||
.select({ id: classes.id, name: classes.name })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, params.classId))
|
||||
.limit(1)
|
||||
const className = classRow?.name ?? "Unknown"
|
||||
const className = (await getClassNameById(params.classId)) ?? "Unknown"
|
||||
|
||||
// Get all subjects that have grade records for this class
|
||||
const subjectRows = await db
|
||||
.select({
|
||||
id: subjects.id,
|
||||
name: subjects.name,
|
||||
})
|
||||
.from(subjects)
|
||||
.innerJoin(gradeRecords, eq(gradeRecords.subjectId, subjects.id))
|
||||
.where(eq(gradeRecords.classId, params.classId))
|
||||
.groupBy(subjects.id, subjects.name)
|
||||
|
||||
// Get all students with grades in this class
|
||||
const studentRows = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
})
|
||||
.from(users)
|
||||
.innerJoin(gradeRecords, eq(gradeRecords.studentId, users.id))
|
||||
.where(eq(gradeRecords.classId, params.classId))
|
||||
.groupBy(users.id, users.name)
|
||||
.orderBy(users.name)
|
||||
|
||||
// Build a map: studentId -> subjectId -> average score
|
||||
// Get all grade records for this class (already includes student/subject names via cross-module interfaces)
|
||||
const allRecords = await getGradeRecords({
|
||||
scope: params.scope,
|
||||
classId: params.classId,
|
||||
})
|
||||
|
||||
// Extract unique subjects and students from the records
|
||||
const subjectIds = Array.from(new Set(allRecords.map((r) => r.subjectId).filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
const studentIds = Array.from(new Set(allRecords.map((r) => r.studentId)))
|
||||
|
||||
const [subjectOptions, studentNameMap] = await Promise.all([
|
||||
getSubjectOptions(),
|
||||
getUserNamesByIds(studentIds),
|
||||
])
|
||||
|
||||
const subjectRows = subjectIds
|
||||
.map((id) => {
|
||||
const subject = subjectOptions.find((s) => s.id === id)
|
||||
return subject ? { id: subject.id, name: subject.name } : null
|
||||
})
|
||||
.filter((s): s is { id: string; name: string } => s !== null)
|
||||
|
||||
const studentRows = studentIds
|
||||
.map((id) => {
|
||||
const info = studentNameMap.get(id)
|
||||
return { id, name: info?.name ?? "Unknown" }
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
// Build a map: studentId -> subjectId -> average score
|
||||
const scoreMap = new Map<string, Map<string, number[]>>()
|
||||
for (const r of allRecords) {
|
||||
if (!scoreMap.has(r.studentId)) scoreMap.set(r.studentId, new Map())
|
||||
const subjMap = scoreMap.get(r.studentId)!
|
||||
const subjMap = scoreMap.get(r.studentId)
|
||||
if (!subjMap) continue
|
||||
const arr = subjMap.get(r.subjectId) ?? []
|
||||
arr.push(r.score)
|
||||
subjMap.set(r.subjectId, arr)
|
||||
@@ -175,7 +167,7 @@ export async function exportClassGradeReportToExcel(params: {
|
||||
const rowsData = studentRows.map((student) => {
|
||||
const subjMap = scoreMap.get(student.id) ?? new Map<string, number[]>()
|
||||
const row: Record<string, unknown> = {
|
||||
studentName: student.name ?? "Unknown",
|
||||
studentName: student.name,
|
||||
}
|
||||
let total = 0
|
||||
let count = 0
|
||||
|
||||
@@ -244,7 +244,8 @@ export async function gradeHomeworkSubmissionAction(
|
||||
try {
|
||||
await requirePermission(Permissions.HOMEWORK_GRADE)
|
||||
|
||||
const rawAnswers = formData.get("answersJson") as string | null
|
||||
const rawAnswersValue = formData.get("answersJson")
|
||||
const rawAnswers = typeof rawAnswersValue === "string" ? rawAnswersValue : null
|
||||
const parsed = GradeHomeworkSchema.safeParse({
|
||||
submissionId: formData.get("submissionId"),
|
||||
answers: rawAnswers ? JSON.parse(rawAnswers) : [],
|
||||
|
||||
245
src/modules/homework/data-access-classes.ts
Normal file
245
src/modules/homework/data-access-classes.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
exams,
|
||||
homeworkAssignmentTargets,
|
||||
homeworkAssignments,
|
||||
homeworkSubmissions,
|
||||
subjects,
|
||||
} from "@/shared/db/schema"
|
||||
|
||||
/**
|
||||
* This file exposes homework data needed by the classes module.
|
||||
* It exists to preserve the three-layer architecture: classes module
|
||||
* must not directly query homework/exams tables.
|
||||
*
|
||||
* All functions return plain data records; callers are responsible for
|
||||
* any further aggregation/statistics.
|
||||
*/
|
||||
|
||||
export type HomeworkAssignmentWithSubject = {
|
||||
id: string
|
||||
title: string
|
||||
status: string | null
|
||||
createdAt: Date
|
||||
dueAt: Date | null
|
||||
subjectId: string | null
|
||||
subjectName: string | null
|
||||
}
|
||||
|
||||
export type HomeworkAssignmentBrief = {
|
||||
id: string
|
||||
title: string
|
||||
status: string | null
|
||||
createdAt: Date
|
||||
dueAt: Date | null
|
||||
}
|
||||
|
||||
export type HomeworkSubmissionRecord = {
|
||||
id: string
|
||||
assignmentId: string
|
||||
studentId: string
|
||||
status: string | null
|
||||
score: number | null
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export type HomeworkSubmissionScoreRecord = {
|
||||
studentId: string
|
||||
assignmentId: string
|
||||
score: number | null
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export type HomeworkAssignmentSubjectRow = {
|
||||
id: string
|
||||
createdAt: Date
|
||||
subjectId: string | null
|
||||
subjectName: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns assignment IDs that target any of the given students.
|
||||
*/
|
||||
export const getAssignmentIdsForStudents = cache(
|
||||
async (studentIds: string[]): Promise<string[]> => {
|
||||
if (studentIds.length === 0) return []
|
||||
const rows = await db
|
||||
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
|
||||
return rows.map((r) => r.assignmentId)
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns homework assignments joined with subject info (via source exam),
|
||||
* optionally filtered by subject IDs. Used by class-level homework insights.
|
||||
*/
|
||||
export const getHomeworkAssignmentsWithSubject = cache(
|
||||
async (params: {
|
||||
assignmentIds: string[]
|
||||
subjectIdFilter?: string[]
|
||||
limit?: number
|
||||
}): Promise<HomeworkAssignmentWithSubject[]> => {
|
||||
if (params.assignmentIds.length === 0) return []
|
||||
const conditions = [inArray(homeworkAssignments.id, params.assignmentIds)]
|
||||
if (params.subjectIdFilter && params.subjectIdFilter.length > 0) {
|
||||
conditions.push(inArray(exams.subjectId, params.subjectIdFilter))
|
||||
}
|
||||
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
|
||||
const rows = await db
|
||||
.select({
|
||||
id: homeworkAssignments.id,
|
||||
title: homeworkAssignments.title,
|
||||
status: homeworkAssignments.status,
|
||||
createdAt: homeworkAssignments.createdAt,
|
||||
dueAt: homeworkAssignments.dueAt,
|
||||
subjectId: exams.subjectId,
|
||||
subjectName: subjects.name,
|
||||
})
|
||||
.from(homeworkAssignments)
|
||||
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
|
||||
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(homeworkAssignments.createdAt))
|
||||
.limit(limit)
|
||||
return rows
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns homework assignments (without subject info) by IDs.
|
||||
* Used by grade-level homework insights where subject filtering is not needed.
|
||||
*/
|
||||
export const getHomeworkAssignmentsByIds = cache(
|
||||
async (params: { assignmentIds: string[]; limit?: number }): Promise<HomeworkAssignmentBrief[]> => {
|
||||
if (params.assignmentIds.length === 0) return []
|
||||
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
|
||||
const rows = await db.query.homeworkAssignments.findMany({
|
||||
where: inArray(homeworkAssignments.id, params.assignmentIds),
|
||||
orderBy: [desc(homeworkAssignments.createdAt)],
|
||||
limit,
|
||||
columns: {
|
||||
id: true,
|
||||
title: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
dueAt: true,
|
||||
},
|
||||
})
|
||||
return rows
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns max score per assignment (sum of question scores).
|
||||
* Re-exported from data-access for classes module convenience.
|
||||
*/
|
||||
export { getAssignmentMaxScoreById } from "./data-access"
|
||||
|
||||
/**
|
||||
* Returns target counts per assignment for the given students.
|
||||
*/
|
||||
export const getAssignmentTargetCounts = cache(
|
||||
async (params: { assignmentIds: string[]; studentIds: string[] }): Promise<Map<string, number>> => {
|
||||
if (params.assignmentIds.length === 0 || params.studentIds.length === 0) return new Map()
|
||||
const rows = await db
|
||||
.select({
|
||||
assignmentId: homeworkAssignmentTargets.assignmentId,
|
||||
targetCount: sql<number>`COUNT(*)`,
|
||||
})
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(
|
||||
and(
|
||||
inArray(homeworkAssignmentTargets.assignmentId, params.assignmentIds),
|
||||
inArray(homeworkAssignmentTargets.studentId, params.studentIds)
|
||||
)
|
||||
)
|
||||
.groupBy(homeworkAssignmentTargets.assignmentId)
|
||||
const map = new Map<string, number>()
|
||||
for (const r of rows) map.set(r.assignmentId, Number(r.targetCount ?? 0))
|
||||
return map
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns homework submissions for given assignments and students,
|
||||
* ordered by createdAt desc so callers can pick the latest per
|
||||
* (assignmentId, studentId) pair.
|
||||
*/
|
||||
export const getHomeworkSubmissionsForStudents = cache(
|
||||
async (params: { assignmentIds: string[]; studentIds: string[] }): Promise<HomeworkSubmissionRecord[]> => {
|
||||
if (params.assignmentIds.length === 0 || params.studentIds.length === 0) return []
|
||||
const rows = await db.query.homeworkSubmissions.findMany({
|
||||
where: and(
|
||||
inArray(homeworkSubmissions.assignmentId, params.assignmentIds),
|
||||
inArray(homeworkSubmissions.studentId, params.studentIds)
|
||||
),
|
||||
orderBy: [desc(homeworkSubmissions.createdAt)],
|
||||
columns: {
|
||||
id: true,
|
||||
assignmentId: true,
|
||||
studentId: true,
|
||||
status: true,
|
||||
score: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
return rows
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns published homework assignments joined with subject info (via source exam).
|
||||
* Used by student subject score aggregation.
|
||||
*/
|
||||
export const getPublishedHomeworkAssignmentsWithSubject = cache(
|
||||
async (params: { assignmentIds: string[] }): Promise<HomeworkAssignmentSubjectRow[]> => {
|
||||
if (params.assignmentIds.length === 0) return []
|
||||
const rows = await db
|
||||
.select({
|
||||
id: homeworkAssignments.id,
|
||||
createdAt: homeworkAssignments.createdAt,
|
||||
subjectId: exams.subjectId,
|
||||
subjectName: subjects.name,
|
||||
})
|
||||
.from(homeworkAssignments)
|
||||
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
|
||||
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
|
||||
.where(
|
||||
and(
|
||||
inArray(homeworkAssignments.id, params.assignmentIds),
|
||||
eq(homeworkAssignments.status, "published")
|
||||
)
|
||||
)
|
||||
.orderBy(desc(homeworkAssignments.createdAt))
|
||||
return rows
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns homework submissions for the given assignments,
|
||||
* ordered by createdAt desc so callers can pick the latest per
|
||||
* (assignmentId, studentId) pair.
|
||||
*/
|
||||
export const getHomeworkSubmissionsForAssignments = cache(
|
||||
async (assignmentIds: string[]): Promise<HomeworkSubmissionScoreRecord[]> => {
|
||||
if (assignmentIds.length === 0) return []
|
||||
const rows = await db
|
||||
.select({
|
||||
studentId: homeworkSubmissions.studentId,
|
||||
assignmentId: homeworkSubmissions.assignmentId,
|
||||
score: homeworkSubmissions.score,
|
||||
createdAt: homeworkSubmissions.createdAt,
|
||||
})
|
||||
.from(homeworkSubmissions)
|
||||
.where(inArray(homeworkSubmissions.assignmentId, assignmentIds))
|
||||
.orderBy(desc(homeworkSubmissions.createdAt))
|
||||
return rows
|
||||
}
|
||||
)
|
||||
@@ -5,16 +5,21 @@ import { and, count, eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classes,
|
||||
classEnrollments,
|
||||
classSubjectTeachers,
|
||||
exams,
|
||||
homeworkAnswers,
|
||||
homeworkAssignmentQuestions,
|
||||
homeworkAssignmentTargets,
|
||||
homeworkAssignments,
|
||||
homeworkSubmissions,
|
||||
} from "@/shared/db/schema"
|
||||
import {
|
||||
getActiveStudentIdsByClassId,
|
||||
getClassTeacherById as getClassTeacherIdFromClass,
|
||||
getTeacherSubjectIdsByClass,
|
||||
} from "@/modules/classes/data-access"
|
||||
import {
|
||||
getExamWithQuestionsForHomework as getExamWithQuestionsFromExams,
|
||||
type ExamWithQuestionsForHomework,
|
||||
} from "@/modules/exams/data-access"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
// ---- Types ----
|
||||
@@ -25,13 +30,7 @@ export type HomeworkExamQuestionData = {
|
||||
order: number | null
|
||||
}
|
||||
|
||||
export type HomeworkExamData = {
|
||||
id: string
|
||||
title: string
|
||||
subjectId: string | null
|
||||
structure: unknown
|
||||
questions: HomeworkExamQuestionData[]
|
||||
}
|
||||
export type HomeworkExamData = ExamWithQuestionsForHomework
|
||||
|
||||
export type HomeworkSubmissionPermissionData = {
|
||||
id: string
|
||||
@@ -63,85 +62,38 @@ export type CreateHomeworkAssignmentData = {
|
||||
}
|
||||
|
||||
// ---- Query helpers (for permission/validation in actions) ----
|
||||
// These delegate to cross-module data-access interfaces to avoid direct DB queries.
|
||||
|
||||
export const getClassTeacherById = async (
|
||||
classId: string
|
||||
): Promise<{ id: string; teacherId: string } | null> => {
|
||||
const [row] = await db
|
||||
.select({ id: classes.id, teacherId: classes.teacherId })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, classId))
|
||||
.limit(1)
|
||||
return row ?? null
|
||||
): Promise<{ id: string; teacherId: string | null } | null> => {
|
||||
const teacherId = await getClassTeacherIdFromClass(classId)
|
||||
if (teacherId === null) return null
|
||||
return { id: classId, teacherId }
|
||||
}
|
||||
|
||||
export const getExamWithQuestionsForHomework = async (
|
||||
examId: string
|
||||
): Promise<HomeworkExamData | null> => {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
with: {
|
||||
questions: {
|
||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!exam) return null
|
||||
return {
|
||||
id: exam.id,
|
||||
title: exam.title,
|
||||
subjectId: exam.subjectId,
|
||||
structure: exam.structure,
|
||||
questions: exam.questions.map((q) => ({
|
||||
questionId: q.questionId,
|
||||
score: q.score ?? null,
|
||||
order: q.order ?? null,
|
||||
})),
|
||||
}
|
||||
return await getExamWithQuestionsFromExams(examId)
|
||||
}
|
||||
|
||||
export const getTeacherAssignedSubjectIds = async (
|
||||
classId: string,
|
||||
teacherId: string
|
||||
): Promise<string[]> => {
|
||||
const rows = await db
|
||||
.select({ subjectId: classSubjectTeachers.subjectId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(
|
||||
and(
|
||||
eq(classSubjectTeachers.classId, classId),
|
||||
eq(classSubjectTeachers.teacherId, teacherId)
|
||||
)
|
||||
)
|
||||
return rows.map((r) => r.subjectId)
|
||||
return await getTeacherSubjectIdsByClass(classId, teacherId)
|
||||
}
|
||||
|
||||
export const getActiveClassStudentIdsForHomework = async (
|
||||
classId: string,
|
||||
dataScope: DataScope,
|
||||
userId: string,
|
||||
classTeacherId: string
|
||||
_dataScope: DataScope,
|
||||
_userId: string,
|
||||
_classTeacherId: string | null
|
||||
): Promise<string[]> => {
|
||||
const classScope =
|
||||
dataScope.type === "all"
|
||||
? eq(classes.id, classId)
|
||||
: classTeacherId === userId
|
||||
? eq(classes.teacherId, userId)
|
||||
: eq(classes.id, classId)
|
||||
|
||||
const rows = await db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||
.where(
|
||||
and(
|
||||
eq(classEnrollments.classId, classId),
|
||||
eq(classEnrollments.status, "active"),
|
||||
classScope
|
||||
)
|
||||
)
|
||||
|
||||
return rows.map((r) => r.studentId)
|
||||
// Permission/scope filtering is handled by requirePermission in actions.ts.
|
||||
// This function returns active students for the class via the classes data-access interface.
|
||||
return await getActiveStudentIdsByClassId(classId)
|
||||
}
|
||||
|
||||
export const getHomeworkSubmissionForPermission = async (
|
||||
@@ -301,17 +253,19 @@ export const gradeHomeworkAnswers = async (
|
||||
submissionId: string,
|
||||
answers: Array<{ id: string; score: number; feedback: string | null }>
|
||||
): Promise<void> => {
|
||||
let totalScore = 0
|
||||
for (const ans of answers) {
|
||||
await db
|
||||
.update(homeworkAnswers)
|
||||
.set({ score: ans.score, feedback: ans.feedback, updatedAt: new Date() })
|
||||
.where(eq(homeworkAnswers.id, ans.id))
|
||||
totalScore += ans.score
|
||||
}
|
||||
await db.transaction(async (tx) => {
|
||||
let totalScore = 0
|
||||
for (const ans of answers) {
|
||||
await tx
|
||||
.update(homeworkAnswers)
|
||||
.set({ score: ans.score, feedback: ans.feedback, updatedAt: new Date() })
|
||||
.where(eq(homeworkAnswers.id, ans.id))
|
||||
totalScore += ans.score
|
||||
}
|
||||
|
||||
await db
|
||||
.update(homeworkSubmissions)
|
||||
.set({ score: totalScore, status: "graded", updatedAt: new Date() })
|
||||
.where(eq(homeworkSubmissions.id, submissionId))
|
||||
await tx
|
||||
.update(homeworkSubmissions)
|
||||
.set({ score: totalScore, status: "graded", updatedAt: new Date() })
|
||||
.where(eq(homeworkSubmissions.id, submissionId))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,21 +3,17 @@ import "server-only"
|
||||
import { cache } from "react"
|
||||
import { and, count, desc, eq, inArray, isNull, lte, or, sql } from "drizzle-orm"
|
||||
|
||||
import { auth } from "@/auth"
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classEnrollments,
|
||||
exams,
|
||||
homeworkAnswers,
|
||||
homeworkAssignmentQuestions,
|
||||
homeworkAssignmentTargets,
|
||||
homeworkAssignments,
|
||||
homeworkSubmissions,
|
||||
roles,
|
||||
subjects,
|
||||
users,
|
||||
usersToRoles,
|
||||
} from "@/shared/db/schema"
|
||||
import { getStudentIdsByClassId, getStudentIdsByClassIds } from "@/modules/classes/data-access"
|
||||
import { getExamIdsByGradeIds, getExamSubjectIdMap } from "@/modules/exams/data-access"
|
||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
|
||||
import type {
|
||||
HomeworkAssignmentListItem,
|
||||
@@ -26,6 +22,7 @@ import type {
|
||||
HomeworkAssignmentStatus,
|
||||
HomeworkSubmissionDetails,
|
||||
HomeworkSubmissionListItem,
|
||||
HomeworkSubmissionStatus,
|
||||
StudentHomeworkAssignmentListItem,
|
||||
StudentHomeworkProgressStatus,
|
||||
StudentHomeworkTakeData,
|
||||
@@ -34,9 +31,24 @@ import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
export const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
|
||||
|
||||
const isHomeworkAssignmentStatus = (v: unknown): v is HomeworkAssignmentStatus =>
|
||||
v === "draft" || v === "published" || v === "archived"
|
||||
|
||||
const toHomeworkAssignmentStatus = (v: string | null | undefined): HomeworkAssignmentStatus =>
|
||||
isHomeworkAssignmentStatus(v) ? v : "draft"
|
||||
|
||||
const isHomeworkSubmissionStatus = (v: unknown): v is HomeworkSubmissionStatus =>
|
||||
v === "started" || v === "submitted" || v === "graded"
|
||||
|
||||
const toHomeworkSubmissionStatus = (v: string | null | undefined): HomeworkSubmissionStatus =>
|
||||
isHomeworkSubmissionStatus(v) ? v : "started"
|
||||
|
||||
const isHomeworkQuestionContent = (v: unknown): v is HomeworkQuestionContent =>
|
||||
isRecord(v)
|
||||
|
||||
export const toQuestionContent = (v: unknown): HomeworkQuestionContent | null => {
|
||||
if (!isRecord(v)) return null
|
||||
return v as HomeworkQuestionContent
|
||||
if (!isHomeworkQuestionContent(v)) return null
|
||||
return v
|
||||
}
|
||||
|
||||
export const getAssignmentMaxScoreById = async (assignmentIds: string[]): Promise<Map<string, number>> => {
|
||||
@@ -63,17 +75,14 @@ export const getHomeworkAssignments = cache(async (params?: { creatorId?: string
|
||||
if (params?.creatorId) conditions.push(eq(homeworkAssignments.creatorId, params.creatorId))
|
||||
if (params?.ids && params.ids.length > 0) conditions.push(inArray(homeworkAssignments.id, params.ids))
|
||||
if (params?.classId) {
|
||||
const classStudentIds = db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(eq(classEnrollments.classId, params.classId))
|
||||
const classStudentIds = await getStudentIdsByClassId(params.classId)
|
||||
|
||||
const targetAssignmentIds = db
|
||||
const targetAssignmentIds = await db
|
||||
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
|
||||
|
||||
conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds))
|
||||
conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds.map((t) => t.assignmentId)))
|
||||
}
|
||||
|
||||
// Data scope filtering
|
||||
@@ -83,24 +92,18 @@ export const getHomeworkAssignments = cache(async (params?: { creatorId?: string
|
||||
}
|
||||
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
|
||||
// Filter homework by assignments targeting students in teacher's classes
|
||||
const classStudentIds = db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(inArray(classEnrollments.classId, params.scope.classIds))
|
||||
const classStudentIds = await getStudentIdsByClassIds(params.scope.classIds)
|
||||
|
||||
const targetAssignmentIds = db
|
||||
const targetAssignmentIds = await db
|
||||
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
|
||||
|
||||
conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds))
|
||||
conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds.map((t) => t.assignmentId)))
|
||||
}
|
||||
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
|
||||
// Homework links to exam via sourceExamId, exam has gradeId
|
||||
const gradeExamIds = db
|
||||
.select({ id: exams.id })
|
||||
.from(exams)
|
||||
.where(inArray(exams.gradeId, params.scope.gradeIds))
|
||||
const gradeExamIds = await getExamIdsByGradeIds(params.scope.gradeIds)
|
||||
|
||||
conditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||
}
|
||||
@@ -121,7 +124,7 @@ export const getHomeworkAssignments = cache(async (params?: { creatorId?: string
|
||||
sourceExamId: a.sourceExamId,
|
||||
sourceExamTitle: a.sourceExam.title,
|
||||
title: a.title,
|
||||
status: (a.status as HomeworkAssignmentListItem["status"]) ?? "draft",
|
||||
status: toHomeworkAssignmentStatus(a.status),
|
||||
availableAt: a.availableAt ? a.availableAt.toISOString() : null,
|
||||
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
||||
allowLate: a.allowLate,
|
||||
@@ -146,23 +149,17 @@ export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId:
|
||||
// Already filtered by creatorId above
|
||||
}
|
||||
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
|
||||
const classStudentIds = db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(inArray(classEnrollments.classId, params.scope.classIds))
|
||||
const classStudentIds = await getStudentIdsByClassIds(params.scope.classIds)
|
||||
|
||||
const targetAssignmentIds = db
|
||||
const targetAssignmentIds = await db
|
||||
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
|
||||
|
||||
conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds))
|
||||
conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds.map((t) => t.assignmentId)))
|
||||
}
|
||||
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
|
||||
const gradeExamIds = db
|
||||
.select({ id: exams.id })
|
||||
.from(exams)
|
||||
.where(inArray(exams.gradeId, params.scope.gradeIds))
|
||||
const gradeExamIds = await getExamIdsByGradeIds(params.scope.gradeIds)
|
||||
|
||||
conditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||
}
|
||||
@@ -223,7 +220,7 @@ export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId:
|
||||
const item: HomeworkAssignmentReviewListItem = {
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
status: (a.status as HomeworkAssignmentReviewListItem["status"]) ?? "draft",
|
||||
status: toHomeworkAssignmentStatus(a.status),
|
||||
sourceExamTitle: a.sourceExam.title,
|
||||
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
||||
targetCount: targetCountByAssignmentId.get(a.id) ?? 0,
|
||||
@@ -239,18 +236,15 @@ export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: str
|
||||
const conditions = []
|
||||
if (params?.assignmentId) conditions.push(eq(homeworkSubmissions.assignmentId, params.assignmentId))
|
||||
if (params?.classId) {
|
||||
const classStudentIds = db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(eq(classEnrollments.classId, params.classId))
|
||||
const classStudentIds = await getStudentIdsByClassId(params.classId)
|
||||
|
||||
const targetAssignmentIds = db
|
||||
const targetAssignmentIds = await db
|
||||
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
|
||||
|
||||
conditions.push(inArray(homeworkSubmissions.studentId, classStudentIds))
|
||||
conditions.push(inArray(homeworkSubmissions.assignmentId, targetAssignmentIds))
|
||||
conditions.push(inArray(homeworkSubmissions.assignmentId, targetAssignmentIds.map((t) => t.assignmentId)))
|
||||
}
|
||||
if (params?.creatorId) {
|
||||
const creatorAssignmentIds = db
|
||||
@@ -272,18 +266,12 @@ export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: str
|
||||
conditions.push(inArray(homeworkSubmissions.assignmentId, creatorAssignmentIds))
|
||||
}
|
||||
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
|
||||
const classStudentIds = db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(inArray(classEnrollments.classId, params.scope.classIds))
|
||||
const classStudentIds = await getStudentIdsByClassIds(params.scope.classIds)
|
||||
|
||||
conditions.push(inArray(homeworkSubmissions.studentId, classStudentIds))
|
||||
}
|
||||
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
|
||||
const gradeExamIds = db
|
||||
.select({ id: exams.id })
|
||||
.from(exams)
|
||||
.where(inArray(exams.gradeId, params.scope.gradeIds))
|
||||
const gradeExamIds = await getExamIdsByGradeIds(params.scope.gradeIds)
|
||||
|
||||
const gradeAssignmentIds = db
|
||||
.select({ assignmentId: homeworkAssignments.id })
|
||||
@@ -311,7 +299,7 @@ export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: str
|
||||
studentName: s.student.name || "Unknown",
|
||||
submittedAt: s.submittedAt ? s.submittedAt.toISOString() : null,
|
||||
score: s.score ?? null,
|
||||
status: (s.status as HomeworkSubmissionListItem["status"]) ?? "started",
|
||||
status: toHomeworkSubmissionStatus(s.status),
|
||||
isLate: s.isLate,
|
||||
}
|
||||
return item
|
||||
@@ -334,21 +322,13 @@ export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataSc
|
||||
return null
|
||||
}
|
||||
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
|
||||
const gradeExamIds = await db
|
||||
.select({ id: exams.id })
|
||||
.from(exams)
|
||||
.where(inArray(exams.gradeId, scope.gradeIds))
|
||||
const examIds = gradeExamIds.map(e => e.id)
|
||||
const examIds = await getExamIdsByGradeIds(scope.gradeIds)
|
||||
if (!examIds.includes(assignment.sourceExamId)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (scope.type === "class_taught" && scope.classIds.length > 0) {
|
||||
const classStudentIds = await db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(inArray(classEnrollments.classId, scope.classIds))
|
||||
const studentIds = classStudentIds.map(s => s.studentId)
|
||||
const studentIds = await getStudentIdsByClassIds(scope.classIds)
|
||||
if (studentIds.length > 0) {
|
||||
const target = await db.query.homeworkAssignmentTargets.findFirst({
|
||||
where: and(
|
||||
@@ -389,7 +369,7 @@ export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataSc
|
||||
id: assignment.id,
|
||||
title: assignment.title,
|
||||
description: assignment.description,
|
||||
status: (assignment.status as HomeworkAssignmentStatus) ?? "draft",
|
||||
status: toHomeworkAssignmentStatus(assignment.status),
|
||||
sourceExamId: assignment.sourceExamId,
|
||||
sourceExamTitle: assignment.sourceExam.title,
|
||||
structure: assignment.structure as unknown,
|
||||
@@ -464,7 +444,7 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
|
||||
assignmentTitle: submission.assignment.title,
|
||||
studentName: submission.student.name || "Unknown",
|
||||
submittedAt: submission.submittedAt ? submission.submittedAt.toISOString() : null,
|
||||
status: submission.status as HomeworkSubmissionDetails["status"],
|
||||
status: toHomeworkSubmissionStatus(submission.status),
|
||||
totalScore: submission.score,
|
||||
answers: answersWithDetails,
|
||||
prevSubmissionId,
|
||||
@@ -472,22 +452,9 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
|
||||
}
|
||||
})
|
||||
|
||||
export const getDemoStudentUser = cache(async (): Promise<{ id: string; name: string } | null> => {
|
||||
const session = await auth()
|
||||
const userId = String(session?.user?.id ?? "").trim()
|
||||
if (!userId) return null
|
||||
|
||||
const [student] = await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
|
||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
||||
.where(and(eq(users.id, userId), eq(roles.name, "student")))
|
||||
.limit(1)
|
||||
|
||||
if (!student) return null
|
||||
return { id: student.id, name: student.name || "Student" }
|
||||
})
|
||||
// Re-export getDemoStudentUser from users module for backward compatibility.
|
||||
// New code should import getCurrentStudentUser from "@/modules/users/data-access" instead.
|
||||
export { getCurrentStudentUser as getDemoStudentUser } from "@/modules/users/data-access"
|
||||
|
||||
const toStudentProgressStatus = (v: string | null | undefined): StudentHomeworkProgressStatus => {
|
||||
if (v === "started") return "in_progress"
|
||||
@@ -508,15 +475,13 @@ export const getStudentHomeworkAssignments = cache(async (studentId: string): Pr
|
||||
.select({
|
||||
id: homeworkAssignments.id,
|
||||
title: homeworkAssignments.title,
|
||||
sourceExamId: homeworkAssignments.sourceExamId,
|
||||
dueAt: homeworkAssignments.dueAt,
|
||||
availableAt: homeworkAssignments.availableAt,
|
||||
maxAttempts: homeworkAssignments.maxAttempts,
|
||||
createdAt: homeworkAssignments.createdAt,
|
||||
subjectName: subjects.name,
|
||||
})
|
||||
.from(homeworkAssignments)
|
||||
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
|
||||
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
|
||||
.where(
|
||||
and(
|
||||
eq(homeworkAssignments.status, "published"),
|
||||
@@ -528,6 +493,15 @@ export const getStudentHomeworkAssignments = cache(async (studentId: string): Pr
|
||||
|
||||
if (assignments.length === 0) return []
|
||||
|
||||
// Fetch subject names via cross-module interfaces
|
||||
const examIds = assignments.map((a) => a.sourceExamId)
|
||||
const [examSubjectIdMap, subjectOptions] = await Promise.all([
|
||||
getExamSubjectIdMap(examIds),
|
||||
getSubjectOptions(),
|
||||
])
|
||||
const subjectNameById = new Map<string, string>()
|
||||
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
|
||||
|
||||
const assignmentIds = assignments.map((a) => a.id)
|
||||
const submissions = await db.query.homeworkSubmissions.findMany({
|
||||
where: and(eq(homeworkSubmissions.studentId, studentId), inArray(homeworkSubmissions.assignmentId, assignmentIds)),
|
||||
@@ -549,11 +523,13 @@ export const getStudentHomeworkAssignments = cache(async (studentId: string): Pr
|
||||
return assignments.map((a) => {
|
||||
const latest = latestSubmittedByAssignmentId.get(a.id) ?? latestByAssignmentId.get(a.id) ?? null
|
||||
const attemptsUsed = attemptsByAssignmentId.get(a.id) ?? 0
|
||||
const subjectId = examSubjectIdMap.get(a.sourceExamId) ?? null
|
||||
const subjectName = subjectId ? subjectNameById.get(subjectId) ?? null : null
|
||||
|
||||
const item: StudentHomeworkAssignmentListItem = {
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
subjectName: a.subjectName ?? null,
|
||||
subjectName: subjectName ?? null,
|
||||
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
|
||||
availableAt: a.availableAt ? a.availableAt.toISOString() : null,
|
||||
maxAttempts: a.maxAttempts,
|
||||
@@ -642,7 +618,7 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
|
||||
submission: latestSubmission
|
||||
? {
|
||||
id: latestSubmission.id,
|
||||
status: (latestSubmission.status as NonNullable<StudentHomeworkTakeData["submission"]>["status"]) ?? "started",
|
||||
status: toHomeworkSubmissionStatus(latestSubmission.status),
|
||||
attemptNo: latestSubmission.attemptNo,
|
||||
submittedAt: latestSubmission.submittedAt ? latestSubmission.submittedAt.toISOString() : null,
|
||||
score: latestSubmission.score ?? null,
|
||||
|
||||
@@ -5,15 +5,18 @@ import { and, count, desc, eq, inArray, or, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classEnrollments,
|
||||
classes,
|
||||
exams,
|
||||
homeworkAssignmentQuestions,
|
||||
homeworkAssignmentTargets,
|
||||
homeworkAssignments,
|
||||
homeworkSubmissions,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
import {
|
||||
getActiveStudentIdsByClassId,
|
||||
getGradeIdsByClassIds,
|
||||
getStudentActiveClassId,
|
||||
} from "@/modules/classes/data-access"
|
||||
import { getExamIdsByGradeIds } from "@/modules/exams/data-access"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
|
||||
import type {
|
||||
HomeworkAssignmentAnalytics,
|
||||
@@ -27,6 +30,12 @@ import type {
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
import { getAssignmentMaxScoreById, isRecord, toQuestionContent } from "./data-access"
|
||||
|
||||
const isHomeworkAssignmentStatus = (v: unknown): v is HomeworkAssignmentStatus =>
|
||||
v === "draft" || v === "published" || v === "archived"
|
||||
|
||||
const toHomeworkAssignmentStatus = (v: string | null | undefined): HomeworkAssignmentStatus =>
|
||||
isHomeworkAssignmentStatus(v) ? v : "draft"
|
||||
|
||||
/**
|
||||
* Get grade trend data for a teacher's recent assignments.
|
||||
* Used by the teacher dashboard to visualize class performance over time.
|
||||
@@ -217,7 +226,7 @@ export const getHomeworkAssignmentAnalytics = cache(
|
||||
id: assignment.id,
|
||||
title: assignment.title,
|
||||
description: assignment.description,
|
||||
status: (assignment.status as HomeworkAssignmentStatus) ?? "draft",
|
||||
status: toHomeworkAssignmentStatus(assignment.status),
|
||||
sourceExamId: assignment.sourceExamId,
|
||||
sourceExamTitle: assignment.sourceExam.title,
|
||||
structure: assignment.structure as unknown,
|
||||
@@ -310,19 +319,10 @@ export const getStudentDashboardGrades = cache(async (studentId: string): Promis
|
||||
const trend = trendSubmissions.map(toAnalytics)
|
||||
const recent = recentSubmissions.map(toAnalytics).slice(0, 5)
|
||||
|
||||
const enrollment = await db.query.classEnrollments.findFirst({
|
||||
where: and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")),
|
||||
orderBy: (e, { asc }) => [asc(e.createdAt)],
|
||||
})
|
||||
const classId = await getStudentActiveClassId(id)
|
||||
if (!classId) return { trend, recent, ranking: null }
|
||||
|
||||
if (!enrollment) return { trend, recent, ranking: null }
|
||||
|
||||
const classStudents = await db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(and(eq(classEnrollments.classId, enrollment.classId), eq(classEnrollments.status, "active")))
|
||||
|
||||
const classStudentIds = Array.from(new Set(classStudents.map((r) => r.studentId)))
|
||||
const classStudentIds = await getActiveStudentIdsByClassId(classId)
|
||||
const classSize = classStudentIds.length
|
||||
if (classSize === 0) return { trend, recent, ranking: null }
|
||||
|
||||
@@ -363,13 +363,8 @@ export const getStudentDashboardGrades = cache(async (studentId: string): Promis
|
||||
})
|
||||
}
|
||||
|
||||
const classUsers = await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, classStudentIds))
|
||||
|
||||
const nameByStudentId = new Map(classUsers.map((u) => [u.id, u.name ?? "Student"] as const))
|
||||
const myName = nameByStudentId.get(id) ?? "Student"
|
||||
const userNamesMap = await getUserNamesByIds(classStudentIds)
|
||||
const myName = userNamesMap.get(id)?.name ?? "Student"
|
||||
|
||||
const ranked = classStudentIds
|
||||
.map((studentId) => {
|
||||
@@ -422,10 +417,7 @@ export const getHomeworkDashboardStats = cache(async (scope?: DataScope): Promis
|
||||
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, ownedAssignmentIds))
|
||||
}
|
||||
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
|
||||
const gradeExamIds = db
|
||||
.select({ id: exams.id })
|
||||
.from(exams)
|
||||
.where(inArray(exams.gradeId, scope.gradeIds))
|
||||
const gradeExamIds = await getExamIdsByGradeIds(scope.gradeIds)
|
||||
homeworkConditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||
const gradeAssignmentIds = db
|
||||
.select({ id: homeworkAssignments.id })
|
||||
@@ -434,16 +426,9 @@ export const getHomeworkDashboardStats = cache(async (scope?: DataScope): Promis
|
||||
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, gradeAssignmentIds))
|
||||
}
|
||||
if (scope.type === "class_taught" && scope.classIds.length > 0) {
|
||||
const teacherGradeIds = await db
|
||||
.selectDistinct({ gradeId: classes.gradeId })
|
||||
.from(classes)
|
||||
.where(inArray(classes.id, scope.classIds))
|
||||
const gradeIds = teacherGradeIds.map((g) => g.gradeId).filter(Boolean) as string[]
|
||||
const gradeIds = await getGradeIdsByClassIds(scope.classIds)
|
||||
if (gradeIds.length > 0) {
|
||||
const gradeExamIds = db
|
||||
.select({ id: exams.id })
|
||||
.from(exams)
|
||||
.where(inArray(exams.gradeId, gradeIds))
|
||||
const gradeExamIds = await getExamIdsByGradeIds(gradeIds)
|
||||
homeworkConditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||
const gradeAssignmentIds = db
|
||||
.select({ id: homeworkAssignments.id })
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { PermissionDeniedError, requireAuth, requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { sendNotification } from "@/modules/notifications/dispatcher"
|
||||
|
||||
import { SendMessageSchema } from "./schema"
|
||||
import {
|
||||
SendMessageSchema,
|
||||
MessageIdSchema,
|
||||
UpdateNotificationPreferencesSchema,
|
||||
} from "./schema"
|
||||
import {
|
||||
getMessages,
|
||||
getMessageById,
|
||||
@@ -87,9 +91,15 @@ export async function sendMessageAction(
|
||||
export async function markMessageAsReadAction(messageId: string): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||
await markMessageAsRead(messageId, ctx.userId)
|
||||
|
||||
const parsed = MessageIdSchema.safeParse({ messageId })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
await markMessageAsRead(parsed.data.messageId, ctx.userId)
|
||||
revalidatePath("/messages")
|
||||
revalidatePath(`/messages/${messageId}`)
|
||||
revalidatePath(`/messages/${parsed.data.messageId}`)
|
||||
return { success: true, message: "Marked as read" }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
@@ -101,9 +111,15 @@ export async function markMessageAsReadAction(messageId: string): Promise<Action
|
||||
export async function deleteMessageAction(messageId: string): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_DELETE)
|
||||
await deleteMessage(messageId, ctx.userId)
|
||||
|
||||
const parsed = MessageIdSchema.safeParse({ messageId })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
await deleteMessage(parsed.data.messageId, ctx.userId)
|
||||
revalidatePath("/messages")
|
||||
revalidatePath(`/messages/${messageId}`)
|
||||
revalidatePath(`/messages/${parsed.data.messageId}`)
|
||||
return { success: true, message: "Message deleted" }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
@@ -129,11 +145,18 @@ export async function getMessagesAction(
|
||||
export async function getMessageDetailAction(messageId: string): Promise<ActionState<Message>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||
const message = await getMessageById(messageId, ctx.userId)
|
||||
|
||||
const parsed = MessageIdSchema.safeParse({ messageId })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
const validMessageId = parsed.data.messageId
|
||||
const message = await getMessageById(validMessageId, ctx.userId)
|
||||
if (!message) return { success: false, message: "Message not found" }
|
||||
// Auto-mark as read when viewed by receiver
|
||||
if (!message.isRead && message.receiverId === ctx.userId) {
|
||||
await markMessageAsRead(messageId, ctx.userId)
|
||||
await markMessageAsRead(validMessageId, ctx.userId)
|
||||
revalidatePath("/messages")
|
||||
}
|
||||
return { success: true, data: message }
|
||||
@@ -160,7 +183,7 @@ export async function getNotificationsAction(
|
||||
params?: { page?: number; pageSize?: number; unreadOnly?: boolean }
|
||||
): Promise<ActionState<{ items: Notification[]; total: number; page: number; pageSize: number; totalPages: number }>> {
|
||||
try {
|
||||
const ctx = await requireAuth()
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||
const result = await getNotifications(ctx.userId, params)
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
@@ -174,7 +197,7 @@ export async function markNotificationAsReadAction(
|
||||
notificationId: string
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requireAuth()
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||
await markNotificationAsRead(notificationId, ctx.userId)
|
||||
revalidatePath("/messages")
|
||||
return { success: true, message: "Notification marked as read" }
|
||||
@@ -187,7 +210,7 @@ export async function markNotificationAsReadAction(
|
||||
|
||||
export async function markAllNotificationsAsReadAction(): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requireAuth()
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||
await markAllNotificationsAsRead(ctx.userId)
|
||||
revalidatePath("/messages")
|
||||
return { success: true, message: "All notifications marked as read" }
|
||||
@@ -200,7 +223,7 @@ export async function markAllNotificationsAsReadAction(): Promise<ActionState<st
|
||||
|
||||
export async function getNotificationPreferencesAction(): Promise<ActionState<NotificationPreferences>> {
|
||||
try {
|
||||
const ctx = await requireAuth()
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||
const prefs = await getNotificationPreferences(ctx.userId)
|
||||
return { success: true, data: prefs }
|
||||
} catch (e) {
|
||||
@@ -215,12 +238,12 @@ export async function updateNotificationPreferencesAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<NotificationPreferences>> {
|
||||
try {
|
||||
const ctx = await requireAuth()
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||
|
||||
// 从 FormData 中解析布尔值(checkbox 提交 "on" 或不提交)
|
||||
const parseBool = (key: string): boolean => formData.get(key) === "on"
|
||||
|
||||
const input: UpdateNotificationPreferencesInput = {
|
||||
const parsed = UpdateNotificationPreferencesSchema.safeParse({
|
||||
emailEnabled: parseBool("emailEnabled"),
|
||||
smsEnabled: parseBool("smsEnabled"),
|
||||
pushEnabled: parseBool("pushEnabled"),
|
||||
@@ -229,8 +252,14 @@ export async function updateNotificationPreferencesAction(
|
||||
announcementNotifications: parseBool("announcementNotifications"),
|
||||
messageNotifications: parseBool("messageNotifications"),
|
||||
attendanceNotifications: parseBool("attendanceNotifications"),
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
const input: UpdateNotificationPreferencesInput = parsed.data
|
||||
|
||||
const updated = await upsertNotificationPreferences(ctx.userId, input)
|
||||
if (!updated) {
|
||||
return { success: false, message: "Failed to update notification preferences" }
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import "server-only"
|
||||
|
||||
/**
|
||||
* 私信数据访问层
|
||||
*
|
||||
* 职责:
|
||||
* - getMessages / getMessageById / getMessageThread: 私信查询
|
||||
* - createMessage / markMessageAsRead / deleteMessage: 私信 CRUD
|
||||
* - getUnreadMessageCount: 未读私信计数
|
||||
* - getRecipients: 获取收件人列表(按 DataScope 过滤)
|
||||
*
|
||||
* 注意: 通知相关函数(createNotification / getNotifications /
|
||||
* markNotificationAsRead / markAllNotificationsAsRead / getUnreadNotificationCount)
|
||||
* 已迁移到 notifications/data-access.ts(P0-4 / P1-5 修复)。
|
||||
* 本文件通过 re-export 保持向后兼容,现有调用方无需修改 import 路径。
|
||||
*/
|
||||
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, count, desc, eq, inArray, or } from "drizzle-orm"
|
||||
@@ -7,7 +22,6 @@ import { and, count, desc, eq, inArray, or } from "drizzle-orm"
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
messages,
|
||||
messageNotifications,
|
||||
users,
|
||||
classEnrollments,
|
||||
classes,
|
||||
@@ -15,18 +29,16 @@ import {
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
import type {
|
||||
Message,
|
||||
Notification,
|
||||
NotificationType,
|
||||
GetMessagesParams,
|
||||
GetNotificationsParams,
|
||||
CreateMessageInput,
|
||||
CreateNotificationInput,
|
||||
PaginatedResult,
|
||||
RecipientOption,
|
||||
} from "./types"
|
||||
|
||||
const toIso = (d: Date | null | undefined): string | null => (d ? d.toISOString() : null)
|
||||
|
||||
const toIsoRequired = (d: Date): string => d.toISOString()
|
||||
|
||||
interface MessageRow {
|
||||
id: string
|
||||
senderId: string
|
||||
@@ -39,17 +51,6 @@ interface MessageRow {
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
interface NotificationRow {
|
||||
id: string
|
||||
userId: string
|
||||
type: string
|
||||
title: string
|
||||
content: string | null
|
||||
link: string | null
|
||||
isRead: boolean
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
async function resolveUserNames(userIds: string[]): Promise<Map<string, string>> {
|
||||
const uniqueIds = [...new Set(userIds)].filter(Boolean)
|
||||
if (uniqueIds.length === 0) return new Map()
|
||||
@@ -71,18 +72,7 @@ const mapMessage = (r: MessageRow, nameMap: Map<string, string>): Message => ({
|
||||
isRead: r.isRead,
|
||||
readAt: toIso(r.readAt),
|
||||
parentMessageId: r.parentMessageId,
|
||||
createdAt: toIso(r.createdAt) as string,
|
||||
})
|
||||
|
||||
const mapNotification = (r: NotificationRow): Notification => ({
|
||||
id: r.id,
|
||||
userId: r.userId,
|
||||
type: r.type as NotificationType,
|
||||
title: r.title,
|
||||
content: r.content,
|
||||
link: r.link,
|
||||
isRead: r.isRead,
|
||||
createdAt: toIso(r.createdAt) as string,
|
||||
createdAt: toIsoRequired(r.createdAt),
|
||||
})
|
||||
|
||||
export const getMessages = cache(
|
||||
@@ -94,7 +84,10 @@ export const getMessages = cache(
|
||||
const conds = []
|
||||
if (params.type === "inbox") conds.push(eq(messages.receiverId, params.userId))
|
||||
else if (params.type === "sent") conds.push(eq(messages.senderId, params.userId))
|
||||
else conds.push(or(eq(messages.receiverId, params.userId), eq(messages.senderId, params.userId))!)
|
||||
else {
|
||||
const cond = or(eq(messages.receiverId, params.userId), eq(messages.senderId, params.userId))
|
||||
if (cond) conds.push(cond)
|
||||
}
|
||||
|
||||
const where = and(...conds)
|
||||
const [rows, [totalRow]] = await Promise.all([
|
||||
@@ -114,7 +107,7 @@ export const getMessageById = cache(
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(and(eq(messages.id, id), or(eq(messages.senderId, userId), eq(messages.receiverId, userId))!))
|
||||
.where(and(eq(messages.id, id), or(eq(messages.senderId, userId), eq(messages.receiverId, userId))))
|
||||
.limit(1)
|
||||
if (!row) return null
|
||||
const nameMap = await resolveUserNames([row.senderId, row.receiverId])
|
||||
@@ -160,7 +153,7 @@ export async function markMessageAsRead(id: string, userId: string): Promise<voi
|
||||
export async function deleteMessage(id: string, userId: string): Promise<void> {
|
||||
await db
|
||||
.delete(messages)
|
||||
.where(and(eq(messages.id, id), or(eq(messages.senderId, userId), eq(messages.receiverId, userId))!))
|
||||
.where(and(eq(messages.id, id), or(eq(messages.senderId, userId), eq(messages.receiverId, userId))))
|
||||
}
|
||||
|
||||
export const getUnreadMessageCount = cache(async (userId: string): Promise<number> => {
|
||||
@@ -171,59 +164,6 @@ export const getUnreadMessageCount = cache(async (userId: string): Promise<numbe
|
||||
return Number(row?.value ?? 0)
|
||||
})
|
||||
|
||||
export const getNotifications = cache(
|
||||
async (userId: string, params?: GetNotificationsParams): Promise<PaginatedResult<Notification>> => {
|
||||
const page = Math.max(1, params?.page ?? 1)
|
||||
const pageSize = Math.max(1, params?.pageSize ?? 20)
|
||||
const offset = (page - 1) * pageSize
|
||||
const conds = [eq(messageNotifications.userId, userId)]
|
||||
if (params?.unreadOnly) conds.push(eq(messageNotifications.isRead, false))
|
||||
const where = and(...conds)
|
||||
|
||||
const [rows, [totalRow]] = await Promise.all([
|
||||
db.select().from(messageNotifications).where(where).orderBy(desc(messageNotifications.createdAt)).limit(pageSize).offset(offset),
|
||||
db.select({ value: count() }).from(messageNotifications).where(where),
|
||||
])
|
||||
const total = Number(totalRow?.value ?? 0)
|
||||
return { items: rows.map(mapNotification), total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
|
||||
}
|
||||
)
|
||||
|
||||
export async function createNotification(data: CreateNotificationInput): Promise<string> {
|
||||
const id = createId()
|
||||
await db.insert(messageNotifications).values({
|
||||
id,
|
||||
userId: data.userId,
|
||||
type: data.type,
|
||||
title: data.title,
|
||||
content: data.content ?? null,
|
||||
link: data.link ?? null,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
export async function markNotificationAsRead(id: string, userId: string): Promise<void> {
|
||||
await db
|
||||
.update(messageNotifications)
|
||||
.set({ isRead: true })
|
||||
.where(and(eq(messageNotifications.id, id), eq(messageNotifications.userId, userId)))
|
||||
}
|
||||
|
||||
export async function markAllNotificationsAsRead(userId: string): Promise<void> {
|
||||
await db
|
||||
.update(messageNotifications)
|
||||
.set({ isRead: true })
|
||||
.where(and(eq(messageNotifications.userId, userId), eq(messageNotifications.isRead, false)))
|
||||
}
|
||||
|
||||
export const getUnreadNotificationCount = cache(async (userId: string): Promise<number> => {
|
||||
const [row] = await db
|
||||
.select({ value: count() })
|
||||
.from(messageNotifications)
|
||||
.where(and(eq(messageNotifications.userId, userId), eq(messageNotifications.isRead, false)))
|
||||
return Number(row?.value ?? 0)
|
||||
})
|
||||
|
||||
export const getRecipients = cache(
|
||||
async (userId: string, scope: DataScope): Promise<RecipientOption[]> => {
|
||||
if (scope.type === "all") {
|
||||
@@ -250,3 +190,15 @@ export const getRecipients = cache(
|
||||
return []
|
||||
}
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 向后兼容 re-export:通知 CRUD 已迁移到 notifications/data-access.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
createNotification,
|
||||
getNotifications,
|
||||
markNotificationAsRead,
|
||||
markAllNotificationsAsRead,
|
||||
getUnreadNotificationCount,
|
||||
} from "@/modules/notifications/data-access"
|
||||
|
||||
@@ -1,166 +1,11 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { notificationPreferences } from "@/shared/db/schema"
|
||||
import type {
|
||||
NotificationPreferences,
|
||||
UpdateNotificationPreferencesInput,
|
||||
} from "./types"
|
||||
|
||||
const toIso = (d: Date): string => d.toISOString()
|
||||
|
||||
const mapRow = (
|
||||
row: typeof notificationPreferences.$inferSelect
|
||||
): NotificationPreferences => ({
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
emailEnabled: row.emailEnabled,
|
||||
smsEnabled: row.smsEnabled,
|
||||
pushEnabled: row.pushEnabled,
|
||||
homeworkNotifications: row.homeworkNotifications,
|
||||
gradeNotifications: row.gradeNotifications,
|
||||
announcementNotifications: row.announcementNotifications,
|
||||
messageNotifications: row.messageNotifications,
|
||||
attendanceNotifications: row.attendanceNotifications,
|
||||
createdAt: toIso(row.createdAt),
|
||||
updatedAt: toIso(row.updatedAt),
|
||||
})
|
||||
|
||||
// 默认偏好值(首次创建时使用)
|
||||
const DEFAULTS = {
|
||||
emailEnabled: false,
|
||||
smsEnabled: false,
|
||||
pushEnabled: true,
|
||||
homeworkNotifications: true,
|
||||
gradeNotifications: true,
|
||||
announcementNotifications: true,
|
||||
messageNotifications: true,
|
||||
attendanceNotifications: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的通知偏好设置
|
||||
* 如果用户尚无记录,则自动创建一条默认记录并返回
|
||||
* 通知偏好数据访问(向后兼容重导出)
|
||||
*
|
||||
* 注意: 通知偏好函数已迁移到 notifications/preferences.ts(P0-4 / P1-5 修复)。
|
||||
* 本文件通过 re-export 保持向后兼容,现有调用方无需修改 import 路径。
|
||||
*/
|
||||
export const getNotificationPreferences = cache(
|
||||
async (userId: string): Promise<NotificationPreferences> => {
|
||||
// 先查询
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
return mapRow(existing)
|
||||
}
|
||||
|
||||
// 不存在则创建默认记录
|
||||
const id = createId()
|
||||
try {
|
||||
await db.insert(notificationPreferences).values({
|
||||
id,
|
||||
userId,
|
||||
...DEFAULTS,
|
||||
})
|
||||
const [created] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.id, id))
|
||||
.limit(1)
|
||||
if (created) return mapRow(created)
|
||||
} catch {
|
||||
// 并发情况下可能违反唯一约束,回退到查询
|
||||
const [fallback] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, userId))
|
||||
.limit(1)
|
||||
if (fallback) return mapRow(fallback)
|
||||
}
|
||||
|
||||
// 极端情况:返回内存中的默认值(不带 id)
|
||||
return {
|
||||
id: "",
|
||||
userId,
|
||||
...DEFAULTS,
|
||||
createdAt: toIso(new Date()),
|
||||
updatedAt: toIso(new Date()),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 更新(或创建)用户的通知偏好设置
|
||||
* 使用 upsert 语义:存在则更新,不存在则插入
|
||||
*/
|
||||
export async function upsertNotificationPreferences(
|
||||
userId: string,
|
||||
input: UpdateNotificationPreferencesInput
|
||||
): Promise<NotificationPreferences | null> {
|
||||
// 先查询是否存在
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
// 更新
|
||||
const updateData: Partial<typeof notificationPreferences.$inferInsert> = {}
|
||||
if (input.emailEnabled !== undefined) updateData.emailEnabled = input.emailEnabled
|
||||
if (input.smsEnabled !== undefined) updateData.smsEnabled = input.smsEnabled
|
||||
if (input.pushEnabled !== undefined) updateData.pushEnabled = input.pushEnabled
|
||||
if (input.homeworkNotifications !== undefined) updateData.homeworkNotifications = input.homeworkNotifications
|
||||
if (input.gradeNotifications !== undefined) updateData.gradeNotifications = input.gradeNotifications
|
||||
if (input.announcementNotifications !== undefined) updateData.announcementNotifications = input.announcementNotifications
|
||||
if (input.messageNotifications !== undefined) updateData.messageNotifications = input.messageNotifications
|
||||
if (input.attendanceNotifications !== undefined) updateData.attendanceNotifications = input.attendanceNotifications
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return mapRow(existing)
|
||||
}
|
||||
|
||||
await db
|
||||
.update(notificationPreferences)
|
||||
.set(updateData)
|
||||
.where(and(eq(notificationPreferences.id, existing.id), eq(notificationPreferences.userId, userId)))
|
||||
|
||||
const [updated] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.id, existing.id))
|
||||
.limit(1)
|
||||
return updated ? mapRow(updated) : null
|
||||
}
|
||||
|
||||
// 不存在则插入
|
||||
const id = createId()
|
||||
try {
|
||||
await db.insert(notificationPreferences).values({
|
||||
id,
|
||||
userId,
|
||||
emailEnabled: input.emailEnabled ?? DEFAULTS.emailEnabled,
|
||||
smsEnabled: input.smsEnabled ?? DEFAULTS.smsEnabled,
|
||||
pushEnabled: input.pushEnabled ?? DEFAULTS.pushEnabled,
|
||||
homeworkNotifications: input.homeworkNotifications ?? DEFAULTS.homeworkNotifications,
|
||||
gradeNotifications: input.gradeNotifications ?? DEFAULTS.gradeNotifications,
|
||||
announcementNotifications: input.announcementNotifications ?? DEFAULTS.announcementNotifications,
|
||||
messageNotifications: input.messageNotifications ?? DEFAULTS.messageNotifications,
|
||||
attendanceNotifications: input.attendanceNotifications ?? DEFAULTS.attendanceNotifications,
|
||||
})
|
||||
|
||||
const [created] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.id, id))
|
||||
.limit(1)
|
||||
return created ? mapRow(created) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
export {
|
||||
getNotificationPreferences,
|
||||
upsertNotificationPreferences,
|
||||
} from "@/modules/notifications/preferences"
|
||||
|
||||
@@ -16,3 +16,26 @@ export const SendMessageSchema = z
|
||||
}))
|
||||
|
||||
export type SendMessageInput = z.infer<typeof SendMessageSchema>
|
||||
|
||||
/** 校验单个 messageId / notificationId 路径参数 */
|
||||
export const MessageIdSchema = z.object({
|
||||
messageId: z.string().trim().min(1),
|
||||
})
|
||||
|
||||
export type MessageIdInput = z.infer<typeof MessageIdSchema>
|
||||
|
||||
/** 校验通知偏好更新表单(8 个布尔字段,来自 checkbox FormData) */
|
||||
export const UpdateNotificationPreferencesSchema = z.object({
|
||||
emailEnabled: z.boolean(),
|
||||
smsEnabled: z.boolean(),
|
||||
pushEnabled: z.boolean(),
|
||||
homeworkNotifications: z.boolean(),
|
||||
gradeNotifications: z.boolean(),
|
||||
announcementNotifications: z.boolean(),
|
||||
messageNotifications: z.boolean(),
|
||||
attendanceNotifications: z.boolean(),
|
||||
})
|
||||
|
||||
export type UpdateNotificationPreferencesFormInput = z.infer<
|
||||
typeof UpdateNotificationPreferencesSchema
|
||||
>
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/**
|
||||
* 私信模块类型定义
|
||||
*
|
||||
* 注意: 通知相关类型(NotificationType, Notification, NotificationPreferences,
|
||||
* UpdateNotificationPreferencesInput, CreateNotificationInput, GetNotificationsParams,
|
||||
* PaginatedResult)已迁移到 notifications/types.ts(P0-4 / P1-5 修复)。
|
||||
* 本文件通过 re-export 保持向后兼容,现有调用方无需修改 import 路径。
|
||||
*/
|
||||
|
||||
export type MessageType = "inbox" | "sent" | "all"
|
||||
|
||||
export interface Message {
|
||||
@@ -20,29 +29,6 @@ export interface MessageThread {
|
||||
messages: Message[]
|
||||
}
|
||||
|
||||
export type NotificationType = "message" | "announcement" | "homework" | "grade"
|
||||
|
||||
export interface Notification {
|
||||
id: string
|
||||
userId: string
|
||||
type: NotificationType
|
||||
title: string
|
||||
content: string | null
|
||||
link: string | null
|
||||
isRead: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type NotificationListItem = Notification
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface GetMessagesParams {
|
||||
userId: string
|
||||
type: MessageType
|
||||
@@ -50,12 +36,6 @@ export interface GetMessagesParams {
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface GetNotificationsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
unreadOnly?: boolean
|
||||
}
|
||||
|
||||
export interface CreateMessageInput {
|
||||
senderId: string
|
||||
receiverId: string
|
||||
@@ -64,14 +44,6 @@ export interface CreateMessageInput {
|
||||
parentMessageId?: string | null
|
||||
}
|
||||
|
||||
export interface CreateNotificationInput {
|
||||
userId: string
|
||||
type: NotificationType
|
||||
title: string
|
||||
content?: string | null
|
||||
link?: string | null
|
||||
}
|
||||
|
||||
export interface RecipientOption {
|
||||
id: string
|
||||
name: string
|
||||
@@ -79,30 +51,17 @@ export interface RecipientOption {
|
||||
role?: string
|
||||
}
|
||||
|
||||
// 通知偏好设置
|
||||
export interface NotificationPreferences {
|
||||
id: string
|
||||
userId: string
|
||||
emailEnabled: boolean
|
||||
smsEnabled: boolean
|
||||
pushEnabled: boolean
|
||||
homeworkNotifications: boolean
|
||||
gradeNotifications: boolean
|
||||
announcementNotifications: boolean
|
||||
messageNotifications: boolean
|
||||
attendanceNotifications: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// 向后兼容 re-export:通知相关类型已迁移到 notifications/types.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 更新通知偏好的输入(部分字段可选,未提供则保留原值)
|
||||
export interface UpdateNotificationPreferencesInput {
|
||||
emailEnabled?: boolean
|
||||
smsEnabled?: boolean
|
||||
pushEnabled?: boolean
|
||||
homeworkNotifications?: boolean
|
||||
gradeNotifications?: boolean
|
||||
announcementNotifications?: boolean
|
||||
messageNotifications?: boolean
|
||||
attendanceNotifications?: boolean
|
||||
}
|
||||
export type {
|
||||
NotificationType,
|
||||
Notification,
|
||||
NotificationListItem,
|
||||
GetNotificationsParams,
|
||||
CreateNotificationInput,
|
||||
PaginatedResult,
|
||||
NotificationPreferences,
|
||||
UpdateNotificationPreferencesInput,
|
||||
} from "@/modules/notifications/types"
|
||||
|
||||
@@ -11,14 +11,12 @@
|
||||
* 班级通知按教师所教班级过滤,确保教师只能给自己班级发通知。
|
||||
*/
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { classEnrollments, classes } from "@/shared/db/schema"
|
||||
import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
import { getClassExists, getStudentIdsByClassId } from "@/modules/classes/data-access"
|
||||
|
||||
import { sendNotification, sendBatchNotifications } from "./dispatcher"
|
||||
import type { NotificationPayload, ChannelSendResult } from "./types"
|
||||
|
||||
@@ -79,36 +77,29 @@ export async function sendClassNotificationAction(
|
||||
}
|
||||
}
|
||||
|
||||
// 查询班级所有学生
|
||||
const [classRow] = await db
|
||||
.select({ id: classes.id })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, classId))
|
||||
.limit(1)
|
||||
|
||||
if (!classRow) {
|
||||
// 校验班级是否存在
|
||||
const classExists = await getClassExists(classId)
|
||||
if (!classExists) {
|
||||
return { success: false, message: "Class not found" }
|
||||
}
|
||||
|
||||
const enrollments = await db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(eq(classEnrollments.classId, classId))
|
||||
// 查询班级所有学生
|
||||
const studentIds = await getStudentIdsByClassId(classId)
|
||||
|
||||
if (enrollments.length === 0) {
|
||||
if (studentIds.length === 0) {
|
||||
return { success: true, message: "No students in this class", data: [] }
|
||||
}
|
||||
|
||||
// 构造每个学生的通知负载
|
||||
const payloads: NotificationPayload[] = enrollments.map((e) => ({
|
||||
const payloads: NotificationPayload[] = studentIds.map((studentId) => ({
|
||||
...payload,
|
||||
userId: e.studentId,
|
||||
userId: studentId,
|
||||
}))
|
||||
|
||||
const results = await sendBatchNotifications(payloads)
|
||||
return {
|
||||
success: true,
|
||||
message: `Notification sent to ${enrollments.length} students`,
|
||||
message: `Notification sent to ${studentIds.length} students`,
|
||||
data: results,
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -26,7 +26,13 @@ import type { NotificationChannelSender, ChannelRecipient } from "./types"
|
||||
const channel: NotificationChannel = "email"
|
||||
|
||||
/** 从环境变量读取邮件配置 */
|
||||
function getEmailConfig() {
|
||||
function getEmailConfig(): {
|
||||
host: string | undefined
|
||||
port: number
|
||||
user: string | undefined
|
||||
pass: string | undefined
|
||||
from: string
|
||||
} {
|
||||
return {
|
||||
host: process.env.EMAIL_HOST,
|
||||
port: Number(process.env.EMAIL_PORT ?? "587"),
|
||||
|
||||
@@ -3,22 +3,22 @@ import "server-only"
|
||||
/**
|
||||
* 站内消息渠道
|
||||
*
|
||||
* 封装现有 messaging 模块的 data-access.createNotification,
|
||||
* 封装 notifications 模块的 data-access.createNotification,
|
||||
* 将其适配为统一的 NotificationChannelSender 接口。
|
||||
*
|
||||
* 这是默认渠道,总是启用。所有通知都会写入 message_notifications 表,
|
||||
* 用户可在站内通知中心查看。
|
||||
*
|
||||
* 注意: messaging.NotificationType 为 "message" | "announcement" | "homework" | "grade",
|
||||
* 注意: NotificationType 为 "message" | "announcement" | "homework" | "grade",
|
||||
* 而本模块 NotificationPayload.type 为 "info" | "warning" | "error" | "success"。
|
||||
* 此处将 payload.type 作为字符串写入 DB(DB 列为 varchar(128),支持任意值),
|
||||
* 不破坏现有 messaging 模块的类型约束。
|
||||
* 通过 mapPayloadTypeToNotificationType 函数进行语义映射(P0-11 修复),
|
||||
* 不再使用非法的 as 断言。
|
||||
*
|
||||
* 使用动态 import 打破 notifications -> messaging 的静态反向依赖。
|
||||
* 运行时调用链: messaging -> dispatcher -> in-app channel -> messaging.createNotification (存储)
|
||||
* 这是可接受的运行时调用链,但模块级静态依赖必须单向。
|
||||
* P0-4 / P1-5 修复后,createNotification 已迁移到 notifications/data-access.ts,
|
||||
* 不再需要动态 import messaging 模块,消除了 notifications -> messaging 的反向依赖。
|
||||
*/
|
||||
|
||||
import { createNotification } from "../data-access"
|
||||
import type {
|
||||
NotificationPayload,
|
||||
ChannelSendResult,
|
||||
@@ -28,7 +28,34 @@ import type { NotificationChannelSender, ChannelRecipient } from "./types"
|
||||
|
||||
const channel: NotificationChannel = "in_app"
|
||||
|
||||
/** 站内消息发送器(通过动态 import 调用 messaging data-access) */
|
||||
/**
|
||||
* Map NotificationPayload.type (info/warning/error/success) to
|
||||
* NotificationType (message/announcement/homework/grade).
|
||||
*
|
||||
* Since the DB column is varchar(128) and accepts any string,
|
||||
* we map by semantic meaning. "info" maps to "message" as the default
|
||||
* in-app notification category.
|
||||
*/
|
||||
function mapPayloadTypeToNotificationType(
|
||||
payloadType: NotificationPayload["type"]
|
||||
): "message" | "announcement" | "homework" | "grade" {
|
||||
// Map by semantic meaning: info/success -> message (general),
|
||||
// warning -> announcement (needs attention), error -> grade (alert-like),
|
||||
// fallback to message. This is a reasonable default mapping.
|
||||
switch (payloadType) {
|
||||
case "info":
|
||||
case "success":
|
||||
return "message"
|
||||
case "warning":
|
||||
return "announcement"
|
||||
case "error":
|
||||
return "grade"
|
||||
default:
|
||||
return "message"
|
||||
}
|
||||
}
|
||||
|
||||
/** 站内消息发送器(直接调用 notifications data-access) */
|
||||
class InAppChannelSender implements NotificationChannelSender {
|
||||
readonly channel = channel
|
||||
|
||||
@@ -46,12 +73,10 @@ class InAppChannelSender implements NotificationChannelSender {
|
||||
sentAt: new Date(),
|
||||
}
|
||||
}
|
||||
// Dynamic import to break static reverse dependency on messaging module
|
||||
const { createNotification } = await import("@/modules/messaging/data-access")
|
||||
const id = await createNotification({
|
||||
userId: payload.userId,
|
||||
// DB 列为 varchar(128),支持任意字符串;保留 payload.type 语义
|
||||
type: payload.type as "message" | "announcement" | "homework" | "grade",
|
||||
// Map payload.type to NotificationType via type-safe mapping (P0-11)
|
||||
type: mapPayloadTypeToNotificationType(payload.type),
|
||||
title: payload.title,
|
||||
content: payload.content,
|
||||
link: payload.actionUrl ?? null,
|
||||
|
||||
@@ -26,10 +26,22 @@ import type { NotificationChannelSender, ChannelRecipient } from "./types"
|
||||
|
||||
const channel: NotificationChannel = "sms"
|
||||
|
||||
type SmsProvider = "aliyun" | "tencent" | "mock"
|
||||
|
||||
const isSmsProvider = (v: unknown): v is SmsProvider =>
|
||||
v === "aliyun" || v === "tencent" || v === "mock"
|
||||
|
||||
/** 从环境变量读取 SMS 配置 */
|
||||
function getSmsConfig() {
|
||||
function getSmsConfig(): {
|
||||
provider: SmsProvider
|
||||
accessKeyId: string | undefined
|
||||
accessKeySecret: string | undefined
|
||||
signName: string | undefined
|
||||
templateCode: string | undefined
|
||||
} {
|
||||
const rawProvider = process.env.SMS_PROVIDER ?? "mock"
|
||||
return {
|
||||
provider: (process.env.SMS_PROVIDER ?? "mock") as "aliyun" | "tencent" | "mock",
|
||||
provider: isSmsProvider(rawProvider) ? rawProvider : ("mock" as const),
|
||||
accessKeyId: process.env.SMS_ACCESS_KEY_ID,
|
||||
accessKeySecret: process.env.SMS_ACCESS_KEY_SECRET,
|
||||
signName: process.env.SMS_SIGN_NAME,
|
||||
|
||||
@@ -37,7 +37,11 @@ interface TokenCache {
|
||||
let tokenCache: TokenCache | null = null
|
||||
|
||||
/** 从环境变量读取微信配置 */
|
||||
function getWechatConfig() {
|
||||
function getWechatConfig(): {
|
||||
appId: string | undefined
|
||||
appSecret: string | undefined
|
||||
templateId: string | undefined
|
||||
} {
|
||||
return {
|
||||
appId: process.env.WECHAT_APP_ID,
|
||||
appSecret: process.env.WECHAT_APP_SECRET,
|
||||
|
||||
@@ -4,34 +4,126 @@ import "server-only"
|
||||
* 通知数据访问层
|
||||
*
|
||||
* 职责:
|
||||
* - getUserNotificationPreferences: 获取用户通知偏好(复用 messaging 模块)
|
||||
* - createNotification: 创建站内通知记录(message_notifications 表)
|
||||
* - getNotifications / markNotificationAsRead / markAllNotificationsAsRead / getUnreadNotificationCount: 站内通知 CRUD
|
||||
* - getUserContactInfo: 获取用户联系方式(手机/邮箱,用于渠道发送)
|
||||
* - logNotificationSend: 记录发送日志(当前项目无 notification_logs 表,使用 console 输出)
|
||||
*
|
||||
* 表所有权:
|
||||
* - message_notifications(由 notifications 模块统一管理,P0-4 / P1-5 修复后从 messaging 迁移)
|
||||
* - notification_preferences(由 notifications/preferences.ts 管理)
|
||||
*
|
||||
* 注意: users 表当前无 wechatOpenId 字段,wechatOpenId 暂返回 undefined。
|
||||
* 未来扩展 users 表增加 wechat_open_id 列后,此处补充查询即可。
|
||||
*/
|
||||
|
||||
import { cache } from "react"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, count, desc, eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { users } from "@/shared/db/schema"
|
||||
import { getNotificationPreferences } from "@/modules/messaging/notification-preferences"
|
||||
import type { NotificationPreferences } from "@/modules/messaging/types"
|
||||
import { messageNotifications, users } from "@/shared/db/schema"
|
||||
import type { ChannelRecipient } from "./channels/types"
|
||||
import type { ChannelSendResult } from "./types"
|
||||
import type {
|
||||
ChannelSendResult,
|
||||
CreateNotificationInput,
|
||||
GetNotificationsParams,
|
||||
Notification,
|
||||
NotificationType,
|
||||
PaginatedResult,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* 获取用户通知偏好(复用 messaging 模块的 cache 包装函数)。
|
||||
* 若用户无记录,messaging 模块会自动创建默认记录。
|
||||
*/
|
||||
export async function getUserNotificationPreferences(
|
||||
const toIsoRequired = (d: Date): string => d.toISOString()
|
||||
|
||||
const isNotificationType = (v: unknown): v is NotificationType =>
|
||||
v === "message" || v === "announcement" || v === "homework" || v === "grade"
|
||||
|
||||
const toNotificationType = (v: string): NotificationType =>
|
||||
isNotificationType(v) ? v : "message"
|
||||
|
||||
interface NotificationRow {
|
||||
id: string
|
||||
userId: string
|
||||
): Promise<NotificationPreferences> {
|
||||
return getNotificationPreferences(userId)
|
||||
type: string
|
||||
title: string
|
||||
content: string | null
|
||||
link: string | null
|
||||
isRead: boolean
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
const mapNotification = (r: NotificationRow): Notification => ({
|
||||
id: r.id,
|
||||
userId: r.userId,
|
||||
type: toNotificationType(r.type),
|
||||
title: r.title,
|
||||
content: r.content,
|
||||
link: r.link,
|
||||
isRead: r.isRead,
|
||||
createdAt: toIsoRequired(r.createdAt),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 站内通知 CRUD(message_notifications 表)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const getNotifications = cache(
|
||||
async (userId: string, params?: GetNotificationsParams): Promise<PaginatedResult<Notification>> => {
|
||||
const page = Math.max(1, params?.page ?? 1)
|
||||
const pageSize = Math.max(1, params?.pageSize ?? 20)
|
||||
const offset = (page - 1) * pageSize
|
||||
const conds = [eq(messageNotifications.userId, userId)]
|
||||
if (params?.unreadOnly) conds.push(eq(messageNotifications.isRead, false))
|
||||
const where = and(...conds)
|
||||
|
||||
const [rows, [totalRow]] = await Promise.all([
|
||||
db.select().from(messageNotifications).where(where).orderBy(desc(messageNotifications.createdAt)).limit(pageSize).offset(offset),
|
||||
db.select({ value: count() }).from(messageNotifications).where(where),
|
||||
])
|
||||
const total = Number(totalRow?.value ?? 0)
|
||||
return { items: rows.map(mapNotification), total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
|
||||
}
|
||||
)
|
||||
|
||||
export async function createNotification(data: CreateNotificationInput): Promise<string> {
|
||||
const id = createId()
|
||||
await db.insert(messageNotifications).values({
|
||||
id,
|
||||
userId: data.userId,
|
||||
type: data.type,
|
||||
title: data.title,
|
||||
content: data.content ?? null,
|
||||
link: data.link ?? null,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
export async function markNotificationAsRead(id: string, userId: string): Promise<void> {
|
||||
await db
|
||||
.update(messageNotifications)
|
||||
.set({ isRead: true })
|
||||
.where(and(eq(messageNotifications.id, id), eq(messageNotifications.userId, userId)))
|
||||
}
|
||||
|
||||
export async function markAllNotificationsAsRead(userId: string): Promise<void> {
|
||||
await db
|
||||
.update(messageNotifications)
|
||||
.set({ isRead: true })
|
||||
.where(and(eq(messageNotifications.userId, userId), eq(messageNotifications.isRead, false)))
|
||||
}
|
||||
|
||||
export const getUnreadNotificationCount = cache(async (userId: string): Promise<number> => {
|
||||
const [row] = await db
|
||||
.select({ value: count() })
|
||||
.from(messageNotifications)
|
||||
.where(and(eq(messageNotifications.userId, userId), eq(messageNotifications.isRead, false)))
|
||||
return Number(row?.value ?? 0)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 用户联系方式(用于 SMS / Email / WeChat 渠道发送)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 获取用户联系方式(手机号、邮箱)。
|
||||
* wechatOpenId 暂不支持(users 表无此字段),返回 undefined。
|
||||
@@ -62,6 +154,10 @@ export const getUserContactInfo = cache(
|
||||
}
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 发送日志
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 记录通知发送日志。
|
||||
*
|
||||
|
||||
@@ -25,10 +25,10 @@ import { createWechatSender } from "./channels/wechat-channel"
|
||||
import { createEmailSender } from "./channels/email-channel"
|
||||
import { createInAppSender } from "./channels/in-app-channel"
|
||||
import {
|
||||
getUserNotificationPreferences,
|
||||
getUserContactInfo,
|
||||
logNotificationSendBatch,
|
||||
} from "./data-access"
|
||||
import { getNotificationPreferences } from "./preferences"
|
||||
|
||||
/** 渠道发送器实例缓存(避免每次发送重新创建) */
|
||||
interface SenderRegistry {
|
||||
@@ -109,7 +109,7 @@ export async function sendNotification(
|
||||
|
||||
// 并行获取用户偏好和联系方式
|
||||
const [prefs, contact] = await Promise.all([
|
||||
getUserNotificationPreferences(userId),
|
||||
getNotificationPreferences(userId),
|
||||
getUserContactInfo(userId),
|
||||
])
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
*
|
||||
* 对外导出:
|
||||
* - sendNotification / sendBatchNotifications: 分发器入口
|
||||
* - 类型定义: NotificationPayload, ChannelSendResult, NotificationChannel 等
|
||||
* - createNotification / getNotifications / markNotificationAsRead / markAllNotificationsAsRead / getUnreadNotificationCount: 站内通知 CRUD
|
||||
* - getNotificationPreferences / upsertNotificationPreferences: 通知偏好 CRUD
|
||||
* - 类型定义: NotificationPayload, ChannelSendResult, NotificationChannel, NotificationType, Notification, NotificationPreferences 等
|
||||
* - 渠道发送器工厂: createSmsSender, createWechatSender, createEmailSender, createInAppSender
|
||||
*
|
||||
* 典型用法:
|
||||
@@ -20,6 +22,20 @@
|
||||
*/
|
||||
|
||||
export { sendNotification, sendBatchNotifications } from "./dispatcher"
|
||||
export {
|
||||
createNotification,
|
||||
getNotifications,
|
||||
markNotificationAsRead,
|
||||
markAllNotificationsAsRead,
|
||||
getUnreadNotificationCount,
|
||||
getUserContactInfo,
|
||||
logNotificationSend,
|
||||
logNotificationSendBatch,
|
||||
} from "./data-access"
|
||||
export {
|
||||
getNotificationPreferences,
|
||||
upsertNotificationPreferences,
|
||||
} from "./preferences"
|
||||
export type {
|
||||
NotificationChannel,
|
||||
NotificationPayload,
|
||||
@@ -28,6 +44,13 @@ export type {
|
||||
SmsChannelConfig,
|
||||
WechatChannelConfig,
|
||||
EmailChannelConfig,
|
||||
NotificationType,
|
||||
Notification,
|
||||
PaginatedResult,
|
||||
GetNotificationsParams,
|
||||
CreateNotificationInput,
|
||||
NotificationPreferences,
|
||||
UpdateNotificationPreferencesInput,
|
||||
} from "./types"
|
||||
export type { NotificationChannelSender, ChannelRecipient } from "./channels/types"
|
||||
|
||||
|
||||
179
src/modules/notifications/preferences.ts
Normal file
179
src/modules/notifications/preferences.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import "server-only"
|
||||
|
||||
/**
|
||||
* 通知偏好数据访问层
|
||||
*
|
||||
* 职责:
|
||||
* - getNotificationPreferences: 获取用户通知偏好(无记录时自动创建默认记录)
|
||||
* - upsertNotificationPreferences: 更新或创建用户通知偏好
|
||||
*
|
||||
* 表所有权: notification_preferences(由 notifications 模块统一管理)
|
||||
*
|
||||
* 注意: 本文件从 messaging/notification-preferences.ts 迁移而来,
|
||||
* 消除 notifications -> messaging 的反向依赖(P0-4 / P1-5 修复)。
|
||||
*/
|
||||
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { notificationPreferences } from "@/shared/db/schema"
|
||||
import type {
|
||||
NotificationPreferences,
|
||||
UpdateNotificationPreferencesInput,
|
||||
} from "./types"
|
||||
|
||||
const toIso = (d: Date): string => d.toISOString()
|
||||
|
||||
const mapRow = (
|
||||
row: typeof notificationPreferences.$inferSelect
|
||||
): NotificationPreferences => ({
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
emailEnabled: row.emailEnabled,
|
||||
smsEnabled: row.smsEnabled,
|
||||
pushEnabled: row.pushEnabled,
|
||||
homeworkNotifications: row.homeworkNotifications,
|
||||
gradeNotifications: row.gradeNotifications,
|
||||
announcementNotifications: row.announcementNotifications,
|
||||
messageNotifications: row.messageNotifications,
|
||||
attendanceNotifications: row.attendanceNotifications,
|
||||
createdAt: toIso(row.createdAt),
|
||||
updatedAt: toIso(row.updatedAt),
|
||||
})
|
||||
|
||||
// 默认偏好值(首次创建时使用)
|
||||
const DEFAULTS = {
|
||||
emailEnabled: false,
|
||||
smsEnabled: false,
|
||||
pushEnabled: true,
|
||||
homeworkNotifications: true,
|
||||
gradeNotifications: true,
|
||||
announcementNotifications: true,
|
||||
messageNotifications: true,
|
||||
attendanceNotifications: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的通知偏好设置
|
||||
* 如果用户尚无记录,则自动创建一条默认记录并返回
|
||||
*/
|
||||
export const getNotificationPreferences = cache(
|
||||
async (userId: string): Promise<NotificationPreferences> => {
|
||||
// 先查询
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
return mapRow(existing)
|
||||
}
|
||||
|
||||
// 不存在则创建默认记录
|
||||
const id = createId()
|
||||
try {
|
||||
await db.insert(notificationPreferences).values({
|
||||
id,
|
||||
userId,
|
||||
...DEFAULTS,
|
||||
})
|
||||
const [created] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.id, id))
|
||||
.limit(1)
|
||||
if (created) return mapRow(created)
|
||||
} catch {
|
||||
// 并发情况下可能违反唯一约束,回退到查询
|
||||
const [fallback] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, userId))
|
||||
.limit(1)
|
||||
if (fallback) return mapRow(fallback)
|
||||
}
|
||||
|
||||
// 极端情况:返回内存中的默认值(不带 id)
|
||||
return {
|
||||
id: "",
|
||||
userId,
|
||||
...DEFAULTS,
|
||||
createdAt: toIso(new Date()),
|
||||
updatedAt: toIso(new Date()),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 更新(或创建)用户的通知偏好设置
|
||||
* 使用 upsert 语义:存在则更新,不存在则插入
|
||||
*/
|
||||
export async function upsertNotificationPreferences(
|
||||
userId: string,
|
||||
input: UpdateNotificationPreferencesInput
|
||||
): Promise<NotificationPreferences | null> {
|
||||
// 先查询是否存在
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
// 更新
|
||||
const updateData: Partial<typeof notificationPreferences.$inferInsert> = {}
|
||||
if (input.emailEnabled !== undefined) updateData.emailEnabled = input.emailEnabled
|
||||
if (input.smsEnabled !== undefined) updateData.smsEnabled = input.smsEnabled
|
||||
if (input.pushEnabled !== undefined) updateData.pushEnabled = input.pushEnabled
|
||||
if (input.homeworkNotifications !== undefined) updateData.homeworkNotifications = input.homeworkNotifications
|
||||
if (input.gradeNotifications !== undefined) updateData.gradeNotifications = input.gradeNotifications
|
||||
if (input.announcementNotifications !== undefined) updateData.announcementNotifications = input.announcementNotifications
|
||||
if (input.messageNotifications !== undefined) updateData.messageNotifications = input.messageNotifications
|
||||
if (input.attendanceNotifications !== undefined) updateData.attendanceNotifications = input.attendanceNotifications
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
return mapRow(existing)
|
||||
}
|
||||
|
||||
await db
|
||||
.update(notificationPreferences)
|
||||
.set(updateData)
|
||||
.where(and(eq(notificationPreferences.id, existing.id), eq(notificationPreferences.userId, userId)))
|
||||
|
||||
const [updated] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.id, existing.id))
|
||||
.limit(1)
|
||||
return updated ? mapRow(updated) : null
|
||||
}
|
||||
|
||||
// 不存在则插入
|
||||
const id = createId()
|
||||
try {
|
||||
await db.insert(notificationPreferences).values({
|
||||
id,
|
||||
userId,
|
||||
emailEnabled: input.emailEnabled ?? DEFAULTS.emailEnabled,
|
||||
smsEnabled: input.smsEnabled ?? DEFAULTS.smsEnabled,
|
||||
pushEnabled: input.pushEnabled ?? DEFAULTS.pushEnabled,
|
||||
homeworkNotifications: input.homeworkNotifications ?? DEFAULTS.homeworkNotifications,
|
||||
gradeNotifications: input.gradeNotifications ?? DEFAULTS.gradeNotifications,
|
||||
announcementNotifications: input.announcementNotifications ?? DEFAULTS.announcementNotifications,
|
||||
messageNotifications: input.messageNotifications ?? DEFAULTS.messageNotifications,
|
||||
attendanceNotifications: input.attendanceNotifications ?? DEFAULTS.attendanceNotifications,
|
||||
})
|
||||
|
||||
const [created] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.id, id))
|
||||
.limit(1)
|
||||
return created ? mapRow(created) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -6,17 +6,41 @@
|
||||
* - NotificationPayload: 通知负载(跨渠道统一)
|
||||
* - ChannelSendResult: 单次发送结果
|
||||
* - NotificationChannelConfig: 渠道配置(从环境变量加载)
|
||||
*
|
||||
* 此外,本文件还定义了站内通知记录与通知偏好的类型:
|
||||
* - NotificationType / Notification: 站内通知记录(message_notifications 表)
|
||||
* - NotificationPreferences / UpdateNotificationPreferencesInput: 通知偏好(notification_preferences 表)
|
||||
* - CreateNotificationInput / GetNotificationsParams: 通知 CRUD 入参
|
||||
* - PaginatedResult<T>: 分页结果泛型
|
||||
*/
|
||||
|
||||
/** 支持的通知渠道 */
|
||||
export type NotificationChannel = "in_app" | "email" | "sms" | "wechat"
|
||||
|
||||
/** 站内通知类型(message_notifications.type 列) */
|
||||
export type NotificationType = "message" | "announcement" | "homework" | "grade"
|
||||
|
||||
/** 站内通知记录(对应 message_notifications 表的展示形态) */
|
||||
export interface Notification {
|
||||
id: string
|
||||
userId: string
|
||||
type: NotificationType
|
||||
title: string
|
||||
content: string | null
|
||||
link: string | null
|
||||
isRead: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 通知列表项(Notification 的别名,用于列表场景) */
|
||||
export type NotificationListItem = Notification
|
||||
|
||||
/** 通知负载(跨渠道统一格式) */
|
||||
export interface NotificationPayload {
|
||||
userId: string
|
||||
title: string
|
||||
content: string
|
||||
/** 通知语义类型(用于渠道内模板映射,不与 messaging.NotificationType 耦合) */
|
||||
/** 通知语义类型(用于渠道内模板映射,不与 NotificationType 耦合) */
|
||||
type: "info" | "warning" | "error" | "success"
|
||||
metadata?: Record<string, unknown>
|
||||
/** 点击通知后的跳转地址(站内相对路径或外链) */
|
||||
@@ -34,6 +58,59 @@ export interface ChannelSendResult {
|
||||
sentAt: Date
|
||||
}
|
||||
|
||||
/** 通用分页结果 */
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
/** 获取站内通知列表的查询参数 */
|
||||
export interface GetNotificationsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
unreadOnly?: boolean
|
||||
}
|
||||
|
||||
/** 创建站内通知的输入 */
|
||||
export interface CreateNotificationInput {
|
||||
userId: string
|
||||
type: NotificationType
|
||||
title: string
|
||||
content?: string | null
|
||||
link?: string | null
|
||||
}
|
||||
|
||||
/** 通知偏好设置(对应 notification_preferences 表的展示形态) */
|
||||
export interface NotificationPreferences {
|
||||
id: string
|
||||
userId: string
|
||||
emailEnabled: boolean
|
||||
smsEnabled: boolean
|
||||
pushEnabled: boolean
|
||||
homeworkNotifications: boolean
|
||||
gradeNotifications: boolean
|
||||
announcementNotifications: boolean
|
||||
messageNotifications: boolean
|
||||
attendanceNotifications: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** 更新通知偏好的输入(部分字段可选,未提供则保留原值) */
|
||||
export interface UpdateNotificationPreferencesInput {
|
||||
emailEnabled?: boolean
|
||||
smsEnabled?: boolean
|
||||
pushEnabled?: boolean
|
||||
homeworkNotifications?: boolean
|
||||
gradeNotifications?: boolean
|
||||
announcementNotifications?: boolean
|
||||
messageNotifications?: boolean
|
||||
attendanceNotifications?: boolean
|
||||
}
|
||||
|
||||
/** SMS 渠道配置 */
|
||||
export interface SmsChannelConfig {
|
||||
provider: "aliyun" | "tencent" | "mock"
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, asc, eq } from "drizzle-orm"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { parentStudentRelations } from "@/shared/db/schema"
|
||||
import {
|
||||
classes,
|
||||
classEnrollments,
|
||||
grades,
|
||||
parentStudentRelations,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
|
||||
getClassNameById,
|
||||
getStudentActiveClassId,
|
||||
getStudentClasses,
|
||||
getStudentSchedule,
|
||||
} from "@/modules/classes/data-access"
|
||||
import {
|
||||
getStudentDashboardGrades,
|
||||
getStudentHomeworkAssignments,
|
||||
} from "@/modules/homework/data-access"
|
||||
import { getStudentGradeSummary } from "@/modules/grades/data-access"
|
||||
import { getGradeOptions } from "@/modules/school/data-access"
|
||||
import { getUserBasicInfo, getUserNamesByIds } from "@/modules/users/data-access"
|
||||
import type {
|
||||
ChildBasicInfo,
|
||||
ChildDashboardData,
|
||||
ChildHomeworkSummary,
|
||||
ChildScheduleItem,
|
||||
@@ -25,9 +27,15 @@ import type {
|
||||
ParentDashboardData,
|
||||
} from "./types"
|
||||
|
||||
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
||||
type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
|
||||
const isWeekday = (n: number): n is Weekday => n >= 1 && n <= 7
|
||||
|
||||
const toWeekday = (d: Date): Weekday => {
|
||||
const day = d.getDay()
|
||||
return (day === 0 ? 7 : day) as 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
// getDay() returns 0 (Sun) - 6 (Sat); normalize Sunday (0) to 7
|
||||
const normalized = day === 0 ? 7 : day
|
||||
return isWeekday(normalized) ? normalized : 1
|
||||
}
|
||||
|
||||
export const getChildren = cache(async (parentId: string): Promise<ParentChildRelation[]> => {
|
||||
@@ -55,66 +63,44 @@ export const getChildren = cache(async (parentId: string): Promise<ParentChildRe
|
||||
}))
|
||||
})
|
||||
|
||||
export const getChildBasicInfo = cache(async (studentId: string, relation: string | null = null) => {
|
||||
const [student] = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
image: users.image,
|
||||
gradeId: users.gradeId,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, studentId))
|
||||
.limit(1)
|
||||
export const getChildBasicInfo = cache(
|
||||
async (
|
||||
studentId: string,
|
||||
relation: string | null = null,
|
||||
): Promise<ChildBasicInfo | null> => {
|
||||
const student = await getUserBasicInfo(studentId)
|
||||
|
||||
if (!student) return null
|
||||
if (!student) return null
|
||||
|
||||
let gradeName: string | null = null
|
||||
if (student.gradeId) {
|
||||
const [grade] = await db
|
||||
.select({ name: grades.name })
|
||||
.from(grades)
|
||||
.where(eq(grades.id, student.gradeId))
|
||||
.limit(1)
|
||||
gradeName = grade?.name ?? null
|
||||
}
|
||||
// gradeName 与 classId 相互独立,并行拉取
|
||||
const [gradeOptions, classId] = await Promise.all([
|
||||
student.gradeId ? getGradeOptions() : Promise.resolve([]),
|
||||
getStudentActiveClassId(studentId),
|
||||
])
|
||||
|
||||
const [enrollment] = await db
|
||||
.select({
|
||||
classId: classEnrollments.classId,
|
||||
status: classEnrollments.status,
|
||||
})
|
||||
.from(classEnrollments)
|
||||
.where(and(eq(classEnrollments.studentId, studentId), eq(classEnrollments.status, "active")))
|
||||
.orderBy(asc(classEnrollments.createdAt))
|
||||
.limit(1)
|
||||
|
||||
let className: string | null = null
|
||||
let classId: string | null = null
|
||||
if (enrollment) {
|
||||
const [cls] = await db
|
||||
.select({ id: classes.id, name: classes.name })
|
||||
.from(classes)
|
||||
.where(eq(classes.id, enrollment.classId))
|
||||
.limit(1)
|
||||
if (cls) {
|
||||
classId = cls.id
|
||||
className = cls.name
|
||||
let gradeName: string | null = null
|
||||
if (student.gradeId) {
|
||||
const grade = gradeOptions.find((g) => g.id === student.gradeId)
|
||||
gradeName = grade?.name ?? null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: student.id,
|
||||
name: student.name,
|
||||
email: student.email,
|
||||
image: student.image,
|
||||
gradeName,
|
||||
className,
|
||||
classId,
|
||||
relation,
|
||||
}
|
||||
})
|
||||
let className: string | null = null
|
||||
if (classId) {
|
||||
className = await getClassNameById(classId)
|
||||
}
|
||||
|
||||
return {
|
||||
id: student.id,
|
||||
name: student.name,
|
||||
email: student.email,
|
||||
image: student.image,
|
||||
gradeName,
|
||||
className,
|
||||
classId,
|
||||
relation,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const buildHomeworkSummary = (
|
||||
assignments: Awaited<ReturnType<typeof getStudentHomeworkAssignments>>,
|
||||
@@ -211,12 +197,12 @@ export const getParentDashboardData = cache(
|
||||
const id = parentId.trim()
|
||||
if (!id) return { parentName: null, children: [] }
|
||||
|
||||
const [parent, relations] = await Promise.all([
|
||||
db.select({ name: users.name }).from(users).where(eq(users.id, id)).limit(1),
|
||||
const [parentInfo, relations] = await Promise.all([
|
||||
getUserNamesByIds([id]),
|
||||
getChildren(id),
|
||||
])
|
||||
|
||||
const parentName = parent[0]?.name ?? null
|
||||
const parentName = parentInfo.get(id)?.name ?? null
|
||||
|
||||
if (relations.length === 0) {
|
||||
return { parentName, children: [] }
|
||||
|
||||
@@ -1,28 +1,23 @@
|
||||
"use server"
|
||||
|
||||
import { ActionState } from "@/shared/types/action-state"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import {
|
||||
requirePermission,
|
||||
requireAuth,
|
||||
PermissionDeniedError,
|
||||
} from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { z } from "zod"
|
||||
import { db } from "@/shared/db"
|
||||
import { examSubmissions } from "@/shared/db/schema"
|
||||
import { and, eq } from "drizzle-orm"
|
||||
|
||||
import {
|
||||
recordProctoringEvent,
|
||||
getExamSubmissionForProctoring,
|
||||
getExamProctoringSummary,
|
||||
getStudentProctoringStatuses,
|
||||
getRecentProctoringEvents,
|
||||
getExamForProctoring,
|
||||
} from "./data-access"
|
||||
import type {
|
||||
ProctoringDashboardData,
|
||||
ProctoringEventType,
|
||||
} from "./types"
|
||||
import type { ProctoringDashboardData } from "./types"
|
||||
|
||||
const ProctoringEventSchema = z.object({
|
||||
submissionId: z.string().min(1),
|
||||
@@ -36,7 +31,7 @@ const ProctoringEventSchema = z.object({
|
||||
"devtools_open",
|
||||
"fullscreen_exit",
|
||||
"idle_timeout",
|
||||
]) as z.ZodType<ProctoringEventType>,
|
||||
]),
|
||||
eventDetail: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -53,14 +48,14 @@ const successState = <T>(data: T, message?: string): ActionState<T> => ({
|
||||
|
||||
/**
|
||||
* 学生端上报监考事件
|
||||
* 使用 requireAuth() 因为是学生上报自己的事件,不需要管理权限
|
||||
* 需要 EXAM_SUBMIT 权限(学生上报自己的事件)
|
||||
*/
|
||||
export async function recordProctoringEventAction(
|
||||
prevState: ActionState<{ id: string }> | null,
|
||||
formData: FormData,
|
||||
): Promise<ActionState<{ id: string }>> {
|
||||
try {
|
||||
const ctx = await requireAuth()
|
||||
const ctx = await requirePermission(Permissions.EXAM_SUBMIT)
|
||||
|
||||
const parsed = ProctoringEventSchema.safeParse({
|
||||
submissionId: formData.get("submissionId"),
|
||||
@@ -76,12 +71,10 @@ export async function recordProctoringEventAction(
|
||||
}
|
||||
|
||||
// 安全校验:submission 必须属于当前学生
|
||||
const submission = await db.query.examSubmissions.findFirst({
|
||||
where: and(
|
||||
eq(examSubmissions.id, parsed.data.submissionId),
|
||||
eq(examSubmissions.studentId, ctx.userId),
|
||||
),
|
||||
})
|
||||
const submission = await getExamSubmissionForProctoring(
|
||||
parsed.data.submissionId,
|
||||
ctx.userId,
|
||||
)
|
||||
if (!submission) {
|
||||
return failState<{ id: string }>("Submission not found for current user")
|
||||
}
|
||||
@@ -94,6 +87,8 @@ export async function recordProctoringEventAction(
|
||||
eventDetail: parsed.data.eventDetail,
|
||||
})
|
||||
|
||||
revalidatePath(`/teacher/exams/${parsed.data.examId}/proctoring`)
|
||||
|
||||
return successState({ id: event.id }, "Event recorded")
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import "server-only"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
exams,
|
||||
examProctoringEvents,
|
||||
examSubmissions,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
import { examProctoringEvents } from "@/shared/db/schema"
|
||||
import { and, desc, eq, gte, lte, sql, inArray } from "drizzle-orm"
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
|
||||
import {
|
||||
getExamForProctoringCrossModule,
|
||||
getExamSubmissionForProctoringCrossModule,
|
||||
getExamSubmissionsForExam,
|
||||
getExamTitleById,
|
||||
} from "@/modules/exams/data-access"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
|
||||
import type {
|
||||
ProctoringEvent,
|
||||
ProctoringEventWithDetails,
|
||||
@@ -21,6 +24,7 @@ import type {
|
||||
ExamModeConfig,
|
||||
ProctoringEventType,
|
||||
ExamMode,
|
||||
SubmissionStatus,
|
||||
} from "./types"
|
||||
import { ABNORMAL_EVENT_THRESHOLD } from "./types"
|
||||
|
||||
@@ -53,6 +57,24 @@ const toExamMode = (value: unknown): ExamMode => {
|
||||
return "homework"
|
||||
}
|
||||
|
||||
const isSubmissionStatus = (v: unknown): v is SubmissionStatus =>
|
||||
v === "started" || v === "submitted" || v === "graded"
|
||||
|
||||
const toSubmissionStatusNullable = (
|
||||
v: string | null,
|
||||
): SubmissionStatus | null => (isSubmissionStatus(v) ? v : null)
|
||||
|
||||
/**
|
||||
* 校验提交记录归属(监考事件上报前的安全校验)
|
||||
* 仅当提交记录存在且属于该学生时返回必要字段,否则返回 null
|
||||
*/
|
||||
export async function getExamSubmissionForProctoring(
|
||||
submissionId: string,
|
||||
studentId: string,
|
||||
): Promise<{ id: string; examId: string; studentId: string } | null> {
|
||||
return getExamSubmissionForProctoringCrossModule(submissionId, studentId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一条监考事件
|
||||
*/
|
||||
@@ -110,26 +132,31 @@ export const getProctoringEvents = cache(
|
||||
const rows = await db
|
||||
.select({
|
||||
event: examProctoringEvents,
|
||||
studentName: users.name,
|
||||
examTitle: exams.title,
|
||||
})
|
||||
.from(examProctoringEvents)
|
||||
.innerJoin(users, eq(users.id, examProctoringEvents.studentId))
|
||||
.innerJoin(exams, eq(exams.id, examProctoringEvents.examId))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(examProctoringEvents.occurredAt))
|
||||
|
||||
if (rows.length === 0) return []
|
||||
|
||||
const studentIds = Array.from(new Set(rows.map((r) => r.event.studentId)))
|
||||
const [userMap, examTitle] = await Promise.all([
|
||||
getUserNamesByIds(studentIds),
|
||||
getExamTitleById(examId),
|
||||
])
|
||||
const resolvedExamTitle = examTitle ?? "未知考试"
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.event.id,
|
||||
submissionId: row.event.submissionId,
|
||||
studentId: row.event.studentId,
|
||||
examId: row.event.examId,
|
||||
eventType: row.event.eventType as ProctoringEventType,
|
||||
eventType: row.event.eventType,
|
||||
eventDetail: row.event.eventDetail,
|
||||
occurredAt: row.event.occurredAt.toISOString(),
|
||||
createdAt: row.event.createdAt.toISOString(),
|
||||
studentName: row.studentName ?? "未知学生",
|
||||
examTitle: row.examTitle,
|
||||
studentName: userMap.get(row.event.studentId)?.name ?? "未知学生",
|
||||
examTitle: resolvedExamTitle,
|
||||
}))
|
||||
},
|
||||
)
|
||||
@@ -149,7 +176,7 @@ export const getProctoringEventsBySubmission = cache(
|
||||
submissionId: row.submissionId,
|
||||
studentId: row.studentId,
|
||||
examId: row.examId,
|
||||
eventType: row.eventType as ProctoringEventType,
|
||||
eventType: row.eventType,
|
||||
eventDetail: row.eventDetail,
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
@@ -162,66 +189,54 @@ export const getProctoringEventsBySubmission = cache(
|
||||
*/
|
||||
export const getExamProctoringSummary = cache(
|
||||
async (examId: string): Promise<ExamProctoringSummary> => {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
columns: {
|
||||
id: true,
|
||||
title: true,
|
||||
examMode: true,
|
||||
},
|
||||
})
|
||||
// 考试信息与提交记录相互独立,并行拉取
|
||||
const [exam, submissions] = await Promise.all([
|
||||
getExamForProctoringCrossModule(examId),
|
||||
getExamSubmissionsForExam(examId),
|
||||
])
|
||||
|
||||
const examTitle = exam?.title ?? "未知考试"
|
||||
const examMode = toExamMode(exam?.examMode)
|
||||
|
||||
// 统计提交记录
|
||||
const submissions = await db.query.examSubmissions.findMany({
|
||||
where: eq(examSubmissions.examId, examId),
|
||||
columns: {
|
||||
id: true,
|
||||
studentId: true,
|
||||
status: true,
|
||||
},
|
||||
})
|
||||
|
||||
const totalStudents = submissions.length
|
||||
const startedStudents = submissions.filter(
|
||||
(s) => s.status === "started",
|
||||
).length
|
||||
const submittedStudents = submissions.filter(
|
||||
(s) => s.status === "submitted" || s.status === "graded",
|
||||
).length
|
||||
// 单次遍历统计 started / submitted
|
||||
let startedStudents = 0
|
||||
let submittedStudents = 0
|
||||
for (const s of submissions) {
|
||||
if (s.status === "started") startedStudents += 1
|
||||
if (s.status === "submitted" || s.status === "graded") submittedStudents += 1
|
||||
}
|
||||
|
||||
// 按事件类型分组统计
|
||||
const eventStats = await db
|
||||
.select({
|
||||
eventType: examProctoringEvents.eventType,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(examProctoringEvents)
|
||||
.where(eq(examProctoringEvents.examId, examId))
|
||||
.groupBy(examProctoringEvents.eventType)
|
||||
// 按事件类型分组统计 与 按学生分组统计 相互独立,并行拉取
|
||||
const [eventStats, studentEventCounts] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
eventType: examProctoringEvents.eventType,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(examProctoringEvents)
|
||||
.where(eq(examProctoringEvents.examId, examId))
|
||||
.groupBy(examProctoringEvents.eventType),
|
||||
db
|
||||
.select({
|
||||
studentId: examProctoringEvents.studentId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(examProctoringEvents)
|
||||
.where(eq(examProctoringEvents.examId, examId))
|
||||
.groupBy(examProctoringEvents.studentId),
|
||||
])
|
||||
|
||||
const eventsByType = emptyEventsByType()
|
||||
let totalEvents = 0
|
||||
for (const stat of eventStats) {
|
||||
const type = stat.eventType as ProctoringEventType
|
||||
const type = stat.eventType
|
||||
if (eventsByType[type] !== undefined) {
|
||||
eventsByType[type] = stat.count
|
||||
totalEvents += stat.count
|
||||
}
|
||||
}
|
||||
|
||||
// 统计异常学生数(事件数 >= 阈值)
|
||||
const studentEventCounts = await db
|
||||
.select({
|
||||
studentId: examProctoringEvents.studentId,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(examProctoringEvents)
|
||||
.where(eq(examProctoringEvents.examId, examId))
|
||||
.groupBy(examProctoringEvents.studentId)
|
||||
|
||||
const abnormalStudents = studentEventCounts.filter(
|
||||
(s) => s.count >= ABNORMAL_EVENT_THRESHOLD,
|
||||
).length
|
||||
@@ -245,21 +260,17 @@ export const getExamProctoringSummary = cache(
|
||||
*/
|
||||
export const getStudentProctoringStatuses = cache(
|
||||
async (examId: string): Promise<StudentProctoringStatus[]> => {
|
||||
// 1. 拉取所有提交记录及学生姓名
|
||||
const submissions = await db
|
||||
.select({
|
||||
submission: examSubmissions,
|
||||
studentName: users.name,
|
||||
})
|
||||
.from(examSubmissions)
|
||||
.innerJoin(users, eq(users.id, examSubmissions.studentId))
|
||||
.where(eq(examSubmissions.examId, examId))
|
||||
// 1. 拉取所有提交记录
|
||||
const submissions = await getExamSubmissionsForExam(examId)
|
||||
|
||||
if (submissions.length === 0) return []
|
||||
|
||||
const studentIds = submissions.map((s) => s.submission.studentId)
|
||||
const studentIds = submissions.map((s) => s.studentId)
|
||||
|
||||
// 2. 拉取这些提交的事件,按学生聚合
|
||||
// 2. 批量获取学生姓名
|
||||
const userMap = await getUserNamesByIds(studentIds)
|
||||
|
||||
// 3. 拉取这些提交的事件,按学生聚合
|
||||
const eventRows = await db
|
||||
.select({
|
||||
studentId: examProctoringEvents.studentId,
|
||||
@@ -275,7 +286,7 @@ export const getStudentProctoringStatuses = cache(
|
||||
)
|
||||
.orderBy(desc(examProctoringEvents.occurredAt))
|
||||
|
||||
// 3. 按学生聚合
|
||||
// 4. 按学生聚合
|
||||
const statsByStudent = new Map<
|
||||
string,
|
||||
{
|
||||
@@ -287,7 +298,7 @@ export const getStudentProctoringStatuses = cache(
|
||||
|
||||
for (const row of eventRows) {
|
||||
const sid = row.studentId
|
||||
const type = row.eventType as ProctoringEventType
|
||||
const type = row.eventType
|
||||
const existing = statsByStudent.get(sid) ?? {
|
||||
count: 0,
|
||||
lastEventAt: null,
|
||||
@@ -303,14 +314,14 @@ export const getStudentProctoringStatuses = cache(
|
||||
statsByStudent.set(sid, existing)
|
||||
}
|
||||
|
||||
return submissions.map((row) => {
|
||||
const studentId = row.submission.studentId
|
||||
return submissions.map((submission) => {
|
||||
const studentId = submission.studentId
|
||||
const stats = statsByStudent.get(studentId)
|
||||
return {
|
||||
studentId,
|
||||
studentName: row.studentName ?? "未知学生",
|
||||
submissionId: row.submission.id,
|
||||
submissionStatus: (row.submission.status ?? null) as StudentProctoringStatus["submissionStatus"],
|
||||
studentName: userMap.get(studentId)?.name ?? "未知学生",
|
||||
submissionId: submission.id,
|
||||
submissionStatus: toSubmissionStatusNullable(submission.status ?? null),
|
||||
eventCount: stats?.count ?? 0,
|
||||
lastEventAt: stats?.lastEventAt ? stats.lastEventAt.toISOString() : null,
|
||||
isAbnormal: (stats?.count ?? 0) >= ABNORMAL_EVENT_THRESHOLD,
|
||||
@@ -330,9 +341,7 @@ export const getExamForProctoring = cache(
|
||||
examMode: ExamMode
|
||||
config: ExamModeConfig
|
||||
} | null> => {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
})
|
||||
const exam = await getExamForProctoringCrossModule(examId)
|
||||
|
||||
if (!exam) return null
|
||||
|
||||
@@ -360,27 +369,32 @@ export const getRecentProctoringEvents = cache(
|
||||
const rows = await db
|
||||
.select({
|
||||
event: examProctoringEvents,
|
||||
studentName: users.name,
|
||||
examTitle: exams.title,
|
||||
})
|
||||
.from(examProctoringEvents)
|
||||
.innerJoin(users, eq(users.id, examProctoringEvents.studentId))
|
||||
.innerJoin(exams, eq(exams.id, examProctoringEvents.examId))
|
||||
.where(eq(examProctoringEvents.examId, examId))
|
||||
.orderBy(desc(examProctoringEvents.occurredAt))
|
||||
.limit(limit)
|
||||
|
||||
if (rows.length === 0) return []
|
||||
|
||||
const studentIds = Array.from(new Set(rows.map((r) => r.event.studentId)))
|
||||
const [userMap, examTitle] = await Promise.all([
|
||||
getUserNamesByIds(studentIds),
|
||||
getExamTitleById(examId),
|
||||
])
|
||||
const resolvedExamTitle = examTitle ?? "未知考试"
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.event.id,
|
||||
submissionId: row.event.submissionId,
|
||||
studentId: row.event.studentId,
|
||||
examId: row.event.examId,
|
||||
eventType: row.event.eventType as ProctoringEventType,
|
||||
eventType: row.event.eventType,
|
||||
eventDetail: row.event.eventDetail,
|
||||
occurredAt: row.event.occurredAt.toISOString(),
|
||||
createdAt: row.event.createdAt.toISOString(),
|
||||
studentName: row.studentName ?? "未知学生",
|
||||
examTitle: row.examTitle,
|
||||
studentName: userMap.get(row.event.studentId)?.name ?? "未知学生",
|
||||
examTitle: resolvedExamTitle,
|
||||
}))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guar
|
||||
import { Permissions } from "@/shared/types/permissions";
|
||||
import { CreateQuestionSchema } from "./schema";
|
||||
import type { CreateQuestionInput } from "./schema";
|
||||
import { ActionState } from "@/shared/types/action-state";
|
||||
import type { ActionState } from "@/shared/types/action-state";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
@@ -17,6 +17,12 @@ import {
|
||||
} from "./data-access";
|
||||
import type { KnowledgePointOption } from "./types";
|
||||
|
||||
/** Result type of getQuestions (data + meta) */
|
||||
type QuestionsListResult = Awaited<ReturnType<typeof getQuestions>>;
|
||||
|
||||
/** Result type of getKnowledgePointOptions */
|
||||
type KnowledgePointOptionsResult = KnowledgePointOption[];
|
||||
|
||||
export async function createNestedQuestion(
|
||||
prevState: ActionState<string> | undefined,
|
||||
formData: FormData | CreateQuestionInput
|
||||
@@ -151,26 +157,34 @@ export async function deleteQuestionAction(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getQuestionsAction(params: GetQuestionsParams) {
|
||||
export async function getQuestionsAction(
|
||||
params: GetQuestionsParams
|
||||
): Promise<ActionState<QuestionsListResult>> {
|
||||
try {
|
||||
await requirePermission(Permissions.QUESTION_READ);
|
||||
return await getQuestions(params);
|
||||
const data = await getQuestions(params);
|
||||
return { success: true, data };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
throw e;
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
throw e;
|
||||
const message = e instanceof Error ? e.message : "Failed to fetch questions";
|
||||
return { success: false, message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getKnowledgePointOptionsAction(): Promise<KnowledgePointOption[]> {
|
||||
export async function getKnowledgePointOptionsAction(): Promise<
|
||||
ActionState<KnowledgePointOptionsResult>
|
||||
> {
|
||||
try {
|
||||
await requirePermission(Permissions.QUESTION_READ);
|
||||
return await getKnowledgePointOptions();
|
||||
const data = await getKnowledgePointOptions();
|
||||
return { success: true, data };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
throw e;
|
||||
return { success: false, message: e.message };
|
||||
}
|
||||
throw e;
|
||||
const message = e instanceof Error ? e.message : "Failed to fetch knowledge point options";
|
||||
return { success: false, message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,8 +160,8 @@ export function CreateQuestionDialog({
|
||||
if (!open) return
|
||||
setIsLoadingKnowledgePoints(true)
|
||||
getKnowledgePointOptionsAction()
|
||||
.then((rows) => {
|
||||
setKnowledgePointOptions(rows)
|
||||
.then((result) => {
|
||||
setKnowledgePointOptions(result.success && result.data ? result.data : [])
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to load knowledge points")
|
||||
|
||||
@@ -25,8 +25,8 @@ export function QuestionFilters() {
|
||||
|
||||
useEffect(() => {
|
||||
getKnowledgePointOptionsAction()
|
||||
.then((rows) => {
|
||||
setKnowledgePointOptions(rows)
|
||||
.then((result) => {
|
||||
setKnowledgePointOptions(result.success && result.data ? result.data : [])
|
||||
})
|
||||
.catch(() => {
|
||||
setKnowledgePointOptions([])
|
||||
|
||||
@@ -297,3 +297,43 @@ export async function getKnowledgePointOptions(): Promise<KnowledgePointOption[]
|
||||
grade: row.grade ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-module query interfaces — read-only access for other modules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type QuestionKnowledgePoint = {
|
||||
questionId: string
|
||||
knowledgePointId: string
|
||||
knowledgePointName: string
|
||||
}
|
||||
|
||||
/** Returns knowledge points associated with the given question ids. */
|
||||
export const getKnowledgePointsForQuestions = cache(
|
||||
async (questionIds: string[]): Promise<Map<string, QuestionKnowledgePoint[]>> => {
|
||||
const result = new Map<string, QuestionKnowledgePoint[]>()
|
||||
const uniqueIds = Array.from(new Set(questionIds.filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
if (uniqueIds.length === 0) return result
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
questionId: questionsToKnowledgePoints.questionId,
|
||||
knowledgePointId: knowledgePoints.id,
|
||||
knowledgePointName: knowledgePoints.name,
|
||||
})
|
||||
.from(questionsToKnowledgePoints)
|
||||
.innerJoin(knowledgePoints, eq(knowledgePoints.id, questionsToKnowledgePoints.knowledgePointId))
|
||||
.where(inArray(questionsToKnowledgePoints.questionId, uniqueIds))
|
||||
|
||||
for (const r of rows) {
|
||||
const list = result.get(r.questionId) ?? []
|
||||
list.push({
|
||||
questionId: r.questionId,
|
||||
knowledgePointId: r.knowledgePointId,
|
||||
knowledgePointName: r.knowledgePointName,
|
||||
})
|
||||
result.set(r.questionId, list)
|
||||
}
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod"
|
||||
export const QuestionTypeEnum = z.enum(["single_choice", "multiple_choice", "text", "judgment", "composite"])
|
||||
|
||||
export const BaseQuestionSchema = z.object({
|
||||
content: z.any().describe("JSON content for the question (e.g. Slate nodes)"),
|
||||
content: z.unknown().describe("JSON content for the question (e.g. Slate nodes)"),
|
||||
type: QuestionTypeEnum,
|
||||
difficulty: z.number().min(1).max(5).default(1),
|
||||
knowledgePointIds: z.array(z.string()).optional(),
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { eq, or } from "drizzle-orm"
|
||||
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { users } from "@/shared/db/schema"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
|
||||
import {
|
||||
getSchedulingRules,
|
||||
@@ -107,14 +105,8 @@ export async function autoScheduleAction(
|
||||
const teacherIds = Array.from(
|
||||
new Set(subjectRows.map((r) => r.teacherId).filter((v): v is string => v !== null))
|
||||
)
|
||||
const teacherRows =
|
||||
teacherIds.length > 0
|
||||
? await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(teacherIds.length === 1 ? eq(users.id, teacherIds[0]!) : or(...teacherIds.map((id) => eq(users.id, id))))
|
||||
: []
|
||||
const teachersInput = teacherRows.map((t) => ({ id: t.id, name: t.name ?? "Unknown" }))
|
||||
const teacherMap = teacherIds.length > 0 ? await getUserNamesByIds(teacherIds) : new Map()
|
||||
const teachersInput = teacherIds.map((id) => ({ id, name: teacherMap.get(id)?.name ?? "Unknown" }))
|
||||
|
||||
// Load classrooms
|
||||
const classroomRows = await getClassroomsForScheduling()
|
||||
|
||||
@@ -141,8 +141,9 @@ export function validateSchedule(
|
||||
// Pairwise overlap check (class/teacher/classroom)
|
||||
for (let i = 0; i < schedule.length; i += 1) {
|
||||
for (let j = i + 1; j < schedule.length; j += 1) {
|
||||
const a = schedule[i]!
|
||||
const b = schedule[j]!
|
||||
const a = schedule[i]
|
||||
const b = schedule[j]
|
||||
if (!a || !b) continue
|
||||
if (!isOverlap(a, b)) continue
|
||||
|
||||
if (a.teacherId && a.teacherId === b.teacherId) {
|
||||
|
||||
159
src/modules/scheduling/data-access-class-schedule.ts
Normal file
159
src/modules/scheduling/data-access-class-schedule.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import "server-only"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { classSchedule } from "@/shared/db/schema"
|
||||
import {
|
||||
getTeacherIdForMutations,
|
||||
verifyTeacherOwnsClass,
|
||||
} from "@/modules/classes/data-access"
|
||||
import {
|
||||
insertClassScheduleItem,
|
||||
updateClassScheduleItemById,
|
||||
deleteClassScheduleItemById,
|
||||
} from "./data-access"
|
||||
import type {
|
||||
CreateClassScheduleItemInput,
|
||||
UpdateClassScheduleItemInput,
|
||||
} from "./types"
|
||||
|
||||
const isTimeHHMM = (v: string): boolean => /^\d{2}:\d{2}$/.test(v)
|
||||
|
||||
/**
|
||||
* Create a single classSchedule item.
|
||||
* Ownership: the caller (teacher) must own the target class.
|
||||
* DB write is delegated to the unified scheduling write entry point.
|
||||
*/
|
||||
export async function createClassScheduleItem(
|
||||
data: CreateClassScheduleItemInput,
|
||||
): Promise<string> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
|
||||
const classId = data.classId.trim()
|
||||
const course = data.course.trim()
|
||||
const startTime = data.startTime.trim()
|
||||
const endTime = data.endTime.trim()
|
||||
const location = data.location?.trim() || null
|
||||
const weekday = data.weekday
|
||||
|
||||
if (!classId) throw new Error("Class is required")
|
||||
if (!course) throw new Error("Course is required")
|
||||
if (!isTimeHHMM(startTime) || !isTimeHHMM(endTime)) throw new Error("Invalid time format")
|
||||
if (startTime >= endTime) throw new Error("Start time must be earlier than end time")
|
||||
if (weekday < 1 || weekday > 7) throw new Error("Invalid weekday")
|
||||
|
||||
const owned = await verifyTeacherOwnsClass(classId, teacherId)
|
||||
if (!owned) throw new Error("Class not found")
|
||||
|
||||
return insertClassScheduleItem({
|
||||
classId,
|
||||
weekday,
|
||||
startTime,
|
||||
endTime,
|
||||
course,
|
||||
location,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a classSchedule item by id.
|
||||
* Ownership: the teacher must own the class associated with the schedule item
|
||||
* (and the target class when classId is being changed).
|
||||
*/
|
||||
export async function updateClassScheduleItem(
|
||||
scheduleId: string,
|
||||
data: UpdateClassScheduleItemInput,
|
||||
): Promise<void> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
const id = scheduleId.trim()
|
||||
if (!id) throw new Error("Missing schedule id")
|
||||
|
||||
const [existing] = await db
|
||||
.select({
|
||||
id: classSchedule.id,
|
||||
classId: classSchedule.classId,
|
||||
startTime: classSchedule.startTime,
|
||||
endTime: classSchedule.endTime,
|
||||
})
|
||||
.from(classSchedule)
|
||||
.where(eq(classSchedule.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) throw new Error("Schedule item not found")
|
||||
|
||||
const ownedExisting = await verifyTeacherOwnsClass(existing.classId, teacherId)
|
||||
if (!ownedExisting) throw new Error("Schedule item not found")
|
||||
|
||||
const update: Partial<typeof classSchedule.$inferSelect> = {}
|
||||
|
||||
if (typeof data.classId === "string") {
|
||||
const nextClassId = data.classId.trim()
|
||||
if (!nextClassId) throw new Error("Class is required")
|
||||
|
||||
const ownedNext = await verifyTeacherOwnsClass(nextClassId, teacherId)
|
||||
if (!ownedNext) throw new Error("Class not found")
|
||||
update.classId = nextClassId
|
||||
}
|
||||
|
||||
if (typeof data.weekday === "number") {
|
||||
if (data.weekday < 1 || data.weekday > 7) throw new Error("Invalid weekday")
|
||||
update.weekday = data.weekday
|
||||
}
|
||||
|
||||
if (typeof data.course === "string") {
|
||||
const course = data.course.trim()
|
||||
if (!course) throw new Error("Course is required")
|
||||
update.course = course
|
||||
}
|
||||
|
||||
const nextStart = typeof data.startTime === "string" ? data.startTime.trim() : undefined
|
||||
const nextEnd = typeof data.endTime === "string" ? data.endTime.trim() : undefined
|
||||
if (nextStart !== undefined) {
|
||||
if (!isTimeHHMM(nextStart)) throw new Error("Invalid time format")
|
||||
update.startTime = nextStart
|
||||
}
|
||||
if (nextEnd !== undefined) {
|
||||
if (!isTimeHHMM(nextEnd)) throw new Error("Invalid time format")
|
||||
update.endTime = nextEnd
|
||||
}
|
||||
|
||||
if (update.startTime !== undefined || update.endTime !== undefined) {
|
||||
const mergedStart = update.startTime ?? existing.startTime
|
||||
const mergedEnd = update.endTime ?? existing.endTime
|
||||
if (typeof mergedStart === "string" && typeof mergedEnd === "string" && mergedStart >= mergedEnd) {
|
||||
throw new Error("Start time must be earlier than end time")
|
||||
}
|
||||
}
|
||||
|
||||
if (data.location !== undefined) {
|
||||
update.location = data.location?.trim() || null
|
||||
}
|
||||
|
||||
if (Object.keys(update).length === 0) return
|
||||
|
||||
await updateClassScheduleItemById(id, update)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a classSchedule item by id.
|
||||
* Ownership: the teacher must own the class associated with the schedule item.
|
||||
*/
|
||||
export async function deleteClassScheduleItem(scheduleId: string): Promise<void> {
|
||||
const teacherId = await getTeacherIdForMutations()
|
||||
const id = scheduleId.trim()
|
||||
if (!id) throw new Error("Missing schedule id")
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: classSchedule.id, classId: classSchedule.classId })
|
||||
.from(classSchedule)
|
||||
.where(eq(classSchedule.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) throw new Error("Schedule item not found")
|
||||
|
||||
const owned = await verifyTeacherOwnsClass(existing.classId, teacherId)
|
||||
if (!owned) throw new Error("Schedule item not found")
|
||||
|
||||
await deleteClassScheduleItemById(id)
|
||||
}
|
||||
@@ -132,12 +132,13 @@ export async function getScheduleChanges(
|
||||
|
||||
const userMap = new Map<string, string>()
|
||||
if (userIds.length > 0) {
|
||||
const firstId = userIds[0]
|
||||
const userRows = await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(
|
||||
userIds.length === 1
|
||||
? eq(users.id, userIds[0]!)
|
||||
userIds.length === 1 && firstId
|
||||
? eq(users.id, firstId)
|
||||
: or(...userIds.map((id) => eq(users.id, id)))
|
||||
)
|
||||
for (const u of userRows) userMap.set(u.id, u.name ?? "Unknown")
|
||||
@@ -218,8 +219,9 @@ export async function getClassConflicts(classId: string): Promise<ScheduleConfli
|
||||
const conflicts: ScheduleConflict[] = []
|
||||
for (let i = 0; i < rows.length; i += 1) {
|
||||
for (let j = i + 1; j < rows.length; j += 1) {
|
||||
const a = rows[i]!
|
||||
const b = rows[j]!
|
||||
const a = rows[i]
|
||||
const b = rows[j]
|
||||
if (!a || !b) continue
|
||||
if (a.weekday !== b.weekday) continue
|
||||
// Time overlap: a.start < b.end && b.start < a.end
|
||||
if (a.startTime < b.endTime && b.startTime < a.endTime) {
|
||||
@@ -236,14 +238,42 @@ export async function getClassConflicts(classId: string): Promise<ScheduleConfli
|
||||
|
||||
// --- Helpers for scheduling pages ---
|
||||
|
||||
export async function getAdminClassesForScheduling() {
|
||||
/** Lightweight class info for scheduling selectors */
|
||||
export type SchedulingClassOption = {
|
||||
id: string
|
||||
name: string
|
||||
grade: string
|
||||
}
|
||||
|
||||
/** Lightweight teacher info for scheduling selectors */
|
||||
export type SchedulingTeacherOption = {
|
||||
id: string
|
||||
name: string | null
|
||||
email: string
|
||||
}
|
||||
|
||||
/** Lightweight classroom info for scheduling selectors */
|
||||
export type SchedulingClassroomOption = {
|
||||
id: string
|
||||
name: string
|
||||
building: string | null
|
||||
}
|
||||
|
||||
/** Class subject with assigned teacher for scheduling */
|
||||
export type SchedulingClassSubject = {
|
||||
subjectId: string
|
||||
subjectName: string
|
||||
teacherId: string | null
|
||||
}
|
||||
|
||||
export async function getAdminClassesForScheduling(): Promise<SchedulingClassOption[]> {
|
||||
return await db
|
||||
.select({ id: classes.id, name: classes.name, grade: classes.grade })
|
||||
.from(classes)
|
||||
.orderBy(classes.grade, classes.name)
|
||||
}
|
||||
|
||||
export async function getTeachersForScheduling() {
|
||||
export async function getTeachersForScheduling(): Promise<SchedulingTeacherOption[]> {
|
||||
return await db
|
||||
.select({ id: users.id, name: users.name, email: users.email })
|
||||
.from(users)
|
||||
@@ -252,14 +282,16 @@ export async function getTeachersForScheduling() {
|
||||
.orderBy(users.name)
|
||||
}
|
||||
|
||||
export async function getClassroomsForScheduling() {
|
||||
export async function getClassroomsForScheduling(): Promise<SchedulingClassroomOption[]> {
|
||||
return await db
|
||||
.select({ id: classrooms.id, name: classrooms.name, building: classrooms.building })
|
||||
.from(classrooms)
|
||||
.orderBy(classrooms.name)
|
||||
}
|
||||
|
||||
export async function getClassSubjectsForScheduling(classId: string) {
|
||||
export async function getClassSubjectsForScheduling(
|
||||
classId: string
|
||||
): Promise<SchedulingClassSubject[]> {
|
||||
return await db
|
||||
.select({
|
||||
subjectId: subjects.id,
|
||||
|
||||
@@ -2,6 +2,26 @@
|
||||
|
||||
export type ScheduleChangeStatus = "pending" | "approved" | "rejected" | "completed"
|
||||
|
||||
export type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
|
||||
export type CreateClassScheduleItemInput = {
|
||||
classId: string
|
||||
weekday: Weekday
|
||||
startTime: string
|
||||
endTime: string
|
||||
course: string
|
||||
location?: string | null
|
||||
}
|
||||
|
||||
export type UpdateClassScheduleItemInput = {
|
||||
classId?: string
|
||||
weekday?: Weekday
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
course?: string
|
||||
location?: string | null
|
||||
}
|
||||
|
||||
export interface SchedulingRule {
|
||||
id: string
|
||||
classId: string | null
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { after } from "next/server"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { academicYears, departments, grades, schools } from "@/shared/db/schema"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { logAudit } from "@/shared/lib/audit-logger"
|
||||
import { UpsertAcademicYearSchema, UpsertDepartmentSchema, UpsertGradeSchema, UpsertSchoolSchema } from "./schema"
|
||||
import {
|
||||
createAcademicYear,
|
||||
createDepartment,
|
||||
createGrade,
|
||||
createSchool,
|
||||
deleteAcademicYear,
|
||||
deleteDepartment,
|
||||
deleteGrade,
|
||||
deleteSchool,
|
||||
updateAcademicYear,
|
||||
updateDepartment,
|
||||
updateGrade,
|
||||
updateSchool,
|
||||
} from "./data-access"
|
||||
|
||||
export async function createDepartmentAction(
|
||||
prevState: ActionState<string> | undefined,
|
||||
@@ -23,7 +35,7 @@ export async function createDepartmentAction(
|
||||
description: formData.get("description"),
|
||||
})
|
||||
|
||||
await db.insert(departments).values({
|
||||
await createDepartment({
|
||||
id: createId(),
|
||||
name: parsed.name,
|
||||
description: parsed.description ?? null,
|
||||
@@ -50,13 +62,10 @@ export async function updateDepartmentAction(
|
||||
description: formData.get("description"),
|
||||
})
|
||||
|
||||
await db
|
||||
.update(departments)
|
||||
.set({
|
||||
name: parsed.name,
|
||||
description: parsed.description ?? null,
|
||||
})
|
||||
.where(eq(departments.id, departmentId))
|
||||
await updateDepartment(departmentId, {
|
||||
name: parsed.name,
|
||||
description: parsed.description ?? null,
|
||||
})
|
||||
|
||||
revalidatePath("/admin/school/departments")
|
||||
return { success: true, message: "Department updated" }
|
||||
@@ -70,7 +79,7 @@ export async function updateDepartmentAction(
|
||||
export async function deleteDepartmentAction(departmentId: string): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.SCHOOL_MANAGE)
|
||||
await db.delete(departments).where(eq(departments.id, departmentId))
|
||||
await deleteDepartment(departmentId)
|
||||
revalidatePath("/admin/school/departments")
|
||||
return { success: true, message: "Department deleted" }
|
||||
} catch (error) {
|
||||
@@ -93,18 +102,12 @@ export async function createAcademicYearAction(
|
||||
isActive: formData.get("isActive") ?? "false",
|
||||
})
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
if (parsed.isActive) {
|
||||
await tx.update(academicYears).set({ isActive: false })
|
||||
}
|
||||
|
||||
await tx.insert(academicYears).values({
|
||||
id: createId(),
|
||||
name: parsed.name,
|
||||
startDate: new Date(parsed.startDate),
|
||||
endDate: new Date(parsed.endDate),
|
||||
isActive: parsed.isActive,
|
||||
})
|
||||
await createAcademicYear({
|
||||
id: createId(),
|
||||
name: parsed.name,
|
||||
startDate: new Date(parsed.startDate),
|
||||
endDate: new Date(parsed.endDate),
|
||||
isActive: parsed.isActive,
|
||||
})
|
||||
|
||||
revalidatePath("/admin/school/academic-year")
|
||||
@@ -130,20 +133,11 @@ export async function updateAcademicYearAction(
|
||||
isActive: formData.get("isActive") ?? "false",
|
||||
})
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
if (parsed.isActive) {
|
||||
await tx.update(academicYears).set({ isActive: false })
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(academicYears)
|
||||
.set({
|
||||
name: parsed.name,
|
||||
startDate: new Date(parsed.startDate),
|
||||
endDate: new Date(parsed.endDate),
|
||||
isActive: parsed.isActive,
|
||||
})
|
||||
.where(eq(academicYears.id, academicYearId))
|
||||
await updateAcademicYear(academicYearId, {
|
||||
name: parsed.name,
|
||||
startDate: new Date(parsed.startDate),
|
||||
endDate: new Date(parsed.endDate),
|
||||
isActive: parsed.isActive,
|
||||
})
|
||||
|
||||
revalidatePath("/admin/school/academic-year")
|
||||
@@ -158,7 +152,7 @@ export async function updateAcademicYearAction(
|
||||
export async function deleteAcademicYearAction(academicYearId: string): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.SCHOOL_MANAGE)
|
||||
await db.delete(academicYears).where(eq(academicYears.id, academicYearId))
|
||||
await deleteAcademicYear(academicYearId)
|
||||
revalidatePath("/admin/school/academic-year")
|
||||
return { success: true, message: "Academic year deleted" }
|
||||
} catch (error) {
|
||||
@@ -179,13 +173,15 @@ export async function createSchoolAction(
|
||||
code: formData.get("code"),
|
||||
})
|
||||
|
||||
await db.insert(schools).values({
|
||||
await createSchool({
|
||||
id: createId(),
|
||||
name: parsed.name,
|
||||
code: parsed.code?.trim() ? parsed.code.trim() : null,
|
||||
})
|
||||
|
||||
await logAudit({ action: "school.create", module: "school", targetType: "school", detail: { name: parsed.name } })
|
||||
after(() =>
|
||||
logAudit({ action: "school.create", module: "school", targetType: "school", detail: { name: parsed.name } })
|
||||
)
|
||||
|
||||
revalidatePath("/admin/school/schools")
|
||||
return { success: true, message: "School created" }
|
||||
@@ -208,15 +204,20 @@ export async function updateSchoolAction(
|
||||
code: formData.get("code"),
|
||||
})
|
||||
|
||||
await db
|
||||
.update(schools)
|
||||
.set({
|
||||
name: parsed.name,
|
||||
code: parsed.code?.trim() ? parsed.code.trim() : null,
|
||||
})
|
||||
.where(eq(schools.id, schoolId))
|
||||
await updateSchool(schoolId, {
|
||||
name: parsed.name,
|
||||
code: parsed.code?.trim() ? parsed.code.trim() : null,
|
||||
})
|
||||
|
||||
await logAudit({ action: "school.update", module: "school", targetId: schoolId, targetType: "school", detail: { name: parsed.name } })
|
||||
after(() =>
|
||||
logAudit({
|
||||
action: "school.update",
|
||||
module: "school",
|
||||
targetId: schoolId,
|
||||
targetType: "school",
|
||||
detail: { name: parsed.name },
|
||||
})
|
||||
)
|
||||
|
||||
revalidatePath("/admin/school/schools")
|
||||
return { success: true, message: "School updated" }
|
||||
@@ -230,9 +231,11 @@ export async function updateSchoolAction(
|
||||
export async function deleteSchoolAction(schoolId: string): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.SCHOOL_MANAGE)
|
||||
await db.delete(schools).where(eq(schools.id, schoolId))
|
||||
await deleteSchool(schoolId)
|
||||
|
||||
await logAudit({ action: "school.delete", module: "school", targetId: schoolId, targetType: "school" })
|
||||
after(() =>
|
||||
logAudit({ action: "school.delete", module: "school", targetId: schoolId, targetType: "school" })
|
||||
)
|
||||
|
||||
revalidatePath("/admin/school/schools")
|
||||
revalidatePath("/admin/school/grades")
|
||||
@@ -258,7 +261,7 @@ export async function createGradeAction(
|
||||
teachingHeadId: formData.get("teachingHeadId"),
|
||||
})
|
||||
|
||||
await db.insert(grades).values({
|
||||
await createGrade({
|
||||
id: createId(),
|
||||
schoolId: parsed.schoolId,
|
||||
name: parsed.name,
|
||||
@@ -291,16 +294,13 @@ export async function updateGradeAction(
|
||||
teachingHeadId: formData.get("teachingHeadId"),
|
||||
})
|
||||
|
||||
await db
|
||||
.update(grades)
|
||||
.set({
|
||||
schoolId: parsed.schoolId,
|
||||
name: parsed.name,
|
||||
order: parsed.order,
|
||||
gradeHeadId: parsed.gradeHeadId,
|
||||
teachingHeadId: parsed.teachingHeadId,
|
||||
})
|
||||
.where(eq(grades.id, gradeId))
|
||||
await updateGrade(gradeId, {
|
||||
schoolId: parsed.schoolId,
|
||||
name: parsed.name,
|
||||
order: parsed.order,
|
||||
gradeHeadId: parsed.gradeHeadId,
|
||||
teachingHeadId: parsed.teachingHeadId,
|
||||
})
|
||||
|
||||
revalidatePath("/admin/school/grades")
|
||||
return { success: true, message: "Grade updated" }
|
||||
@@ -314,7 +314,7 @@ export async function updateGradeAction(
|
||||
export async function deleteGradeAction(gradeId: string): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.GRADE_MANAGE)
|
||||
await db.delete(grades).where(eq(grades.id, gradeId))
|
||||
await deleteGrade(gradeId)
|
||||
revalidatePath("/admin/school/grades")
|
||||
return { success: true, message: "Grade deleted" }
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { asc, eq, inArray, or } from "drizzle-orm"
|
||||
import { and, asc, eq, inArray, or, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { academicYears, departments, grades, roles, schools, users, usersToRoles } from "@/shared/db/schema"
|
||||
import type { AcademicYearListItem, DepartmentListItem, GradeListItem, SchoolListItem, StaffOption } from "./types"
|
||||
import { academicYears, departments, grades, roles, schools, subjects, users, usersToRoles } from "@/shared/db/schema"
|
||||
import type {
|
||||
AcademicYearInsertData,
|
||||
AcademicYearListItem,
|
||||
AcademicYearUpdateData,
|
||||
DepartmentInsertData,
|
||||
DepartmentListItem,
|
||||
DepartmentUpdateData,
|
||||
GradeInsertData,
|
||||
GradeListItem,
|
||||
GradeUpdateData,
|
||||
SchoolInsertData,
|
||||
SchoolListItem,
|
||||
SchoolUpdateData,
|
||||
StaffOption,
|
||||
} from "./types"
|
||||
|
||||
const toIso = (d: Date) => d.toISOString()
|
||||
|
||||
@@ -19,7 +33,8 @@ export const getDepartments = cache(async (): Promise<DepartmentListItem[]> => {
|
||||
createdAt: toIso(r.createdAt),
|
||||
updatedAt: toIso(r.updatedAt),
|
||||
}))
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getDepartments failed:", error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
@@ -36,7 +51,8 @@ export const getAcademicYears = cache(async (): Promise<AcademicYearListItem[]>
|
||||
createdAt: toIso(r.createdAt),
|
||||
updatedAt: toIso(r.updatedAt),
|
||||
}))
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getAcademicYears failed:", error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
@@ -51,7 +67,8 @@ export const getSchools = cache(async (): Promise<SchoolListItem[]> => {
|
||||
createdAt: toIso(r.createdAt),
|
||||
updatedAt: toIso(r.updatedAt),
|
||||
}))
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getSchools failed:", error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
@@ -104,7 +121,8 @@ export const getGrades = cache(async (): Promise<GradeListItem[]> => {
|
||||
createdAt: toIso(r.createdAt),
|
||||
updatedAt: toIso(r.updatedAt),
|
||||
}))
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getGrades failed:", error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
@@ -125,7 +143,8 @@ export const getStaffOptions = cache(async (): Promise<StaffOption[]> => {
|
||||
name: r.name ?? "Unnamed",
|
||||
email: r.email,
|
||||
}))
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getStaffOptions failed:", error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
@@ -180,7 +199,272 @@ export const getGradesForStaff = cache(async (staffId: string): Promise<GradeLis
|
||||
createdAt: toIso(r.createdAt),
|
||||
updatedAt: toIso(r.updatedAt),
|
||||
}))
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getGradesForStaff failed:", error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutations — DB write operations (called only from actions.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createDepartment(data: DepartmentInsertData): Promise<void> {
|
||||
await db.insert(departments).values({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateDepartment(
|
||||
id: string,
|
||||
data: DepartmentUpdateData
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(departments)
|
||||
.set({ name: data.name, description: data.description })
|
||||
.where(eq(departments.id, id))
|
||||
}
|
||||
|
||||
export async function deleteDepartment(id: string): Promise<void> {
|
||||
await db.delete(departments).where(eq(departments.id, id))
|
||||
}
|
||||
|
||||
export async function createSchool(data: SchoolInsertData): Promise<void> {
|
||||
await db.insert(schools).values({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
code: data.code,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateSchool(
|
||||
id: string,
|
||||
data: SchoolUpdateData
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(schools)
|
||||
.set({ name: data.name, code: data.code })
|
||||
.where(eq(schools.id, id))
|
||||
}
|
||||
|
||||
export async function deleteSchool(id: string): Promise<void> {
|
||||
await db.delete(schools).where(eq(schools.id, id))
|
||||
}
|
||||
|
||||
export async function createGrade(data: GradeInsertData): Promise<void> {
|
||||
await db.insert(grades).values({
|
||||
id: data.id,
|
||||
schoolId: data.schoolId,
|
||||
name: data.name,
|
||||
order: data.order,
|
||||
gradeHeadId: data.gradeHeadId,
|
||||
teachingHeadId: data.teachingHeadId,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateGrade(
|
||||
id: string,
|
||||
data: GradeUpdateData
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(grades)
|
||||
.set({
|
||||
schoolId: data.schoolId,
|
||||
name: data.name,
|
||||
order: data.order,
|
||||
gradeHeadId: data.gradeHeadId,
|
||||
teachingHeadId: data.teachingHeadId,
|
||||
})
|
||||
.where(eq(grades.id, id))
|
||||
}
|
||||
|
||||
export async function deleteGrade(id: string): Promise<void> {
|
||||
await db.delete(grades).where(eq(grades.id, id))
|
||||
}
|
||||
|
||||
export async function createAcademicYear(
|
||||
data: AcademicYearInsertData
|
||||
): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
if (data.isActive) {
|
||||
await tx.update(academicYears).set({ isActive: false })
|
||||
}
|
||||
await tx.insert(academicYears).values({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
startDate: data.startDate,
|
||||
endDate: data.endDate,
|
||||
isActive: data.isActive,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateAcademicYear(
|
||||
id: string,
|
||||
data: AcademicYearUpdateData
|
||||
): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
if (data.isActive) {
|
||||
await tx.update(academicYears).set({ isActive: false })
|
||||
}
|
||||
await tx
|
||||
.update(academicYears)
|
||||
.set({
|
||||
name: data.name,
|
||||
startDate: data.startDate,
|
||||
endDate: data.endDate,
|
||||
isActive: data.isActive,
|
||||
})
|
||||
.where(eq(academicYears.id, id))
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteAcademicYear(id: string): Promise<void> {
|
||||
await db.delete(academicYears).where(eq(academicYears.id, id))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-module query interfaces — read-only access for other modules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SubjectOption = {
|
||||
id: string
|
||||
name: string
|
||||
code: string | null
|
||||
order: number
|
||||
}
|
||||
|
||||
export type GradeOption = {
|
||||
id: string
|
||||
name: string
|
||||
schoolId: string
|
||||
schoolName: string
|
||||
order: number
|
||||
}
|
||||
|
||||
export const getSubjectOptions = cache(async (): Promise<SubjectOption[]> => {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: subjects.id,
|
||||
name: subjects.name,
|
||||
code: subjects.code,
|
||||
order: subjects.order,
|
||||
})
|
||||
.from(subjects)
|
||||
.orderBy(asc(subjects.order), asc(subjects.name))
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
code: r.code ?? null,
|
||||
order: Number(r.order ?? 0),
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("getSubjectOptions failed:", error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
export const getGradeOptions = cache(async (): Promise<GradeOption[]> => {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: grades.id,
|
||||
name: grades.name,
|
||||
order: grades.order,
|
||||
schoolId: schools.id,
|
||||
schoolName: schools.name,
|
||||
})
|
||||
.from(grades)
|
||||
.innerJoin(schools, eq(schools.id, grades.schoolId))
|
||||
.orderBy(asc(schools.name), asc(grades.order), asc(grades.name))
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
schoolId: r.schoolId,
|
||||
schoolName: r.schoolName,
|
||||
order: Number(r.order ?? 0),
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("getGradeOptions failed:", error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-module query interfaces — grade head/teaching head verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 校验用户是否为指定年级的年级主任。
|
||||
* 供 classes 模块跨模块调用使用,避免直接查询 grades 表。
|
||||
*/
|
||||
export const isGradeHead = async (
|
||||
gradeId: string,
|
||||
userId: string
|
||||
): Promise<boolean> => {
|
||||
const trimmedGradeId = gradeId.trim()
|
||||
const trimmedUserId = userId.trim()
|
||||
if (!trimmedGradeId || !trimmedUserId) return false
|
||||
|
||||
const [row] = await db
|
||||
.select({ id: grades.id })
|
||||
.from(grades)
|
||||
.where(and(eq(grades.id, trimmedGradeId), eq(grades.gradeHeadId, trimmedUserId)))
|
||||
.limit(1)
|
||||
return Boolean(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验用户是否为指定年级的年级主任或教学主任。
|
||||
* 供 classes 模块跨模块调用使用,避免直接查询 grades 表。
|
||||
*/
|
||||
export const isGradeManager = async (
|
||||
gradeId: string,
|
||||
userId: string
|
||||
): Promise<boolean> => {
|
||||
const trimmedGradeId = gradeId.trim()
|
||||
const trimmedUserId = userId.trim()
|
||||
if (!trimmedGradeId || !trimmedUserId) return false
|
||||
|
||||
const [row] = await db
|
||||
.select({ id: grades.id })
|
||||
.from(grades)
|
||||
.where(
|
||||
and(
|
||||
eq(grades.id, trimmedGradeId),
|
||||
or(eq(grades.gradeHeadId, trimmedUserId), eq(grades.teachingHeadId, trimmedUserId))
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
return Boolean(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据年级名称(大小写不敏感)查找用户担任年级主任的年级 ID。
|
||||
* 供 classes 模块跨模块调用使用,避免直接查询 grades 表。
|
||||
*/
|
||||
export const findGradeIdByHeadAndName = async (
|
||||
userId: string,
|
||||
gradeName: string
|
||||
): Promise<string | null> => {
|
||||
const trimmedUserId = userId.trim()
|
||||
const normalizedGradeName = gradeName.trim().toLowerCase()
|
||||
if (!trimmedUserId || !normalizedGradeName) return null
|
||||
|
||||
const [row] = await db
|
||||
.select({ id: grades.id })
|
||||
.from(grades)
|
||||
.where(
|
||||
and(
|
||||
eq(grades.gradeHeadId, trimmedUserId),
|
||||
sql`LOWER(${grades.name}) = ${normalizedGradeName}`
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
return row?.id ?? null
|
||||
}
|
||||
|
||||
@@ -40,3 +40,57 @@ export type GradeListItem = {
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type DepartmentInsertData = {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
}
|
||||
|
||||
export type DepartmentUpdateData = {
|
||||
name: string
|
||||
description: string | null
|
||||
}
|
||||
|
||||
export type SchoolInsertData = {
|
||||
id: string
|
||||
name: string
|
||||
code: string | null
|
||||
}
|
||||
|
||||
export type SchoolUpdateData = {
|
||||
name: string
|
||||
code: string | null
|
||||
}
|
||||
|
||||
export type GradeInsertData = {
|
||||
id: string
|
||||
schoolId: string
|
||||
name: string
|
||||
order: number
|
||||
gradeHeadId: string | null
|
||||
teachingHeadId: string | null
|
||||
}
|
||||
|
||||
export type GradeUpdateData = {
|
||||
schoolId: string
|
||||
name: string
|
||||
order: number
|
||||
gradeHeadId: string | null
|
||||
teachingHeadId: string | null
|
||||
}
|
||||
|
||||
export type AcademicYearInsertData = {
|
||||
id: string
|
||||
name: string
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export type AcademicYearUpdateData = {
|
||||
name: string
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
@@ -1,33 +1,40 @@
|
||||
"use server"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { compare, hash } from "bcryptjs"
|
||||
import { z } from "zod"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { users, passwordSecurity } from "@/shared/db/schema"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { requireAuth, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { validatePassword } from "@/shared/lib/password-policy"
|
||||
import { rateLimit, rateLimitKey, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit"
|
||||
import { normalizeBcryptHash } from "@/shared/lib/bcrypt-utils"
|
||||
|
||||
const normalizeBcryptHash = (value: string) => {
|
||||
if (value.startsWith("$2")) return value
|
||||
if (value.startsWith("$")) return `$2b${value}`
|
||||
return `$2b$${value}`
|
||||
}
|
||||
import {
|
||||
getPasswordSecurityByUserId,
|
||||
getUserPasswordHash,
|
||||
updateUserPassword,
|
||||
upsertPasswordSecurityOnPasswordChange,
|
||||
} from "./data-access"
|
||||
|
||||
const ChangePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, "Current password is required"),
|
||||
newPassword: z.string().min(1, "New password is required"),
|
||||
confirmPassword: z.string().min(1, "Password confirmation is required"),
|
||||
})
|
||||
|
||||
/**
|
||||
* Change the current user's password. Requires only authentication
|
||||
* (no specific permission) since every user can manage their own
|
||||
* credentials. Rate-limited to slow brute-force of the current password.
|
||||
* Change the current user's password. Requires self-service profile update
|
||||
* permission (every authenticated user has it). Rate-limited to slow
|
||||
* brute-force of the current password.
|
||||
*/
|
||||
export async function changePasswordAction(
|
||||
prevState: ActionState<null>,
|
||||
formData: FormData
|
||||
): Promise<ActionState<null>> {
|
||||
try {
|
||||
const ctx = await requireAuth()
|
||||
const ctx = await requirePermission(Permissions.USER_PROFILE_UPDATE)
|
||||
const userId = ctx.userId
|
||||
|
||||
const limitKey = rateLimitKey("pwd-change", userId)
|
||||
@@ -36,13 +43,19 @@ export async function changePasswordAction(
|
||||
return { success: false, message: "Too many attempts. Please try again later." }
|
||||
}
|
||||
|
||||
const currentPassword = String(formData.get("currentPassword") ?? "")
|
||||
const newPassword = String(formData.get("newPassword") ?? "")
|
||||
const confirmPassword = String(formData.get("confirmPassword") ?? "")
|
||||
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
return { success: false, message: "All fields are required" }
|
||||
const parsed = ChangePasswordSchema.safeParse({
|
||||
currentPassword: formData.get("currentPassword"),
|
||||
newPassword: formData.get("newPassword"),
|
||||
confirmPassword: formData.get("confirmPassword"),
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: parsed.error.issues[0]?.message ?? "Invalid form data",
|
||||
}
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword, confirmPassword } = parsed.data
|
||||
if (newPassword !== confirmPassword) {
|
||||
return { success: false, message: "New passwords do not match" }
|
||||
}
|
||||
@@ -55,16 +68,17 @@ export async function changePasswordAction(
|
||||
return { success: false, message: validation.errors[0] ?? "Password does not meet requirements" }
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.select({ id: users.id, password: users.password })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1)
|
||||
if (!user || !user.password) {
|
||||
// Parallelize user and passwordSecurity queries
|
||||
const [userRecord, existingSecurity] = await Promise.all([
|
||||
getUserPasswordHash(userId),
|
||||
getPasswordSecurityByUserId(userId),
|
||||
])
|
||||
|
||||
if (!userRecord || !userRecord.password) {
|
||||
return { success: false, message: "User not found or no password set" }
|
||||
}
|
||||
|
||||
const storedHash = normalizeBcryptHash(user.password)
|
||||
const storedHash = normalizeBcryptHash(userRecord.password)
|
||||
if (!storedHash.startsWith("$2")) {
|
||||
return { success: false, message: "Stored password is invalid" }
|
||||
}
|
||||
@@ -75,34 +89,8 @@ export async function changePasswordAction(
|
||||
|
||||
const newHash = await hash(newPassword, 10)
|
||||
const now = new Date()
|
||||
await db
|
||||
.update(users)
|
||||
.set({ password: newHash, updatedAt: now })
|
||||
.where(eq(users.id, userId))
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: passwordSecurity.id })
|
||||
.from(passwordSecurity)
|
||||
.where(eq(passwordSecurity.userId, userId))
|
||||
.limit(1)
|
||||
if (existing) {
|
||||
await db
|
||||
.update(passwordSecurity)
|
||||
.set({
|
||||
lastPasswordChange: now,
|
||||
passwordChangedAt: now,
|
||||
mustChangePassword: false,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(passwordSecurity.userId, userId))
|
||||
} else {
|
||||
await db.insert(passwordSecurity).values({
|
||||
userId,
|
||||
lastPasswordChange: now,
|
||||
passwordChangedAt: now,
|
||||
mustChangePassword: false,
|
||||
})
|
||||
}
|
||||
await updateUserPassword(userId, newHash, now)
|
||||
await upsertPasswordSecurityOnPasswordChange(userId, now, existingSecurity)
|
||||
|
||||
revalidatePath("/settings")
|
||||
return { success: true, message: "Password changed successfully", data: null }
|
||||
|
||||
@@ -3,15 +3,23 @@
|
||||
import { z } from "zod"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { count, desc, eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { aiProviders } from "@/shared/db/schema"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { encryptAiApiKey, getAiErrorMessage, testAiProviderById, testAiProviderConfig } from "@/shared/lib/ai"
|
||||
|
||||
import {
|
||||
countDefaultAiProviders,
|
||||
createAiProvider,
|
||||
getAiProviderForUpdate,
|
||||
getAiProviderSummaries as fetchAiProviderSummaries,
|
||||
updateAiProvider,
|
||||
} from "./data-access"
|
||||
import type { AiProviderSummary } from "./types"
|
||||
|
||||
export type { AiProviderSummary } from "./types"
|
||||
|
||||
const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom"])
|
||||
|
||||
const AiProviderFormSchema = z.object({
|
||||
@@ -35,22 +43,12 @@ const AiProviderTestSchema = AiProviderFormSchema.extend({
|
||||
}
|
||||
})
|
||||
|
||||
export type AiProviderSummary = {
|
||||
id: string
|
||||
provider: z.infer<typeof ProviderSchema>
|
||||
baseUrl: string | null
|
||||
model: string
|
||||
apiKeyLast4: string | null
|
||||
isDefault: boolean
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
const ensureUser = async () => {
|
||||
const ensureUser = async (): Promise<{ id: string }> => {
|
||||
const ctx = await requirePermission(Permissions.AI_CONFIGURE)
|
||||
return { id: ctx.userId }
|
||||
}
|
||||
|
||||
const normalizeBaseUrl = (value: string | undefined) => {
|
||||
const normalizeBaseUrl = (value: string | undefined): string | null => {
|
||||
const raw = String(value ?? "").trim()
|
||||
if (!raw.length) return null
|
||||
const trimmed = raw.replace(/\/+$/, "")
|
||||
@@ -61,19 +59,7 @@ const normalizeBaseUrl = (value: string | undefined) => {
|
||||
|
||||
export async function getAiProviderSummaries(): Promise<AiProviderSummary[]> {
|
||||
await ensureUser()
|
||||
const rows = await db
|
||||
.select({
|
||||
id: aiProviders.id,
|
||||
provider: aiProviders.provider,
|
||||
baseUrl: aiProviders.baseUrl,
|
||||
model: aiProviders.model,
|
||||
apiKeyLast4: aiProviders.apiKeyLast4,
|
||||
isDefault: aiProviders.isDefault,
|
||||
updatedAt: aiProviders.updatedAt,
|
||||
})
|
||||
.from(aiProviders)
|
||||
.orderBy(desc(aiProviders.updatedAt))
|
||||
return rows
|
||||
return fetchAiProviderSummaries()
|
||||
}
|
||||
|
||||
export async function upsertAiProviderAction(
|
||||
@@ -92,51 +78,39 @@ export async function upsertAiProviderAction(
|
||||
return { success: false, message: "Base URL is required for this provider" }
|
||||
}
|
||||
|
||||
const [defaultRow] = await db
|
||||
.select({ value: count() })
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.isDefault, true))
|
||||
const defaultCount = Number(defaultRow?.value ?? 0)
|
||||
// Parallelize default-count and existing-provider queries
|
||||
const [defaultCount, existing] = await Promise.all([
|
||||
countDefaultAiProviders(),
|
||||
payload.id ? getAiProviderForUpdate(payload.id) : Promise.resolve(null),
|
||||
])
|
||||
const hasDefault = defaultCount > 0
|
||||
|
||||
if (payload.id) {
|
||||
const id = payload.id
|
||||
const [existing] = await db
|
||||
.select({
|
||||
id: aiProviders.id,
|
||||
apiKeyEncrypted: aiProviders.apiKeyEncrypted,
|
||||
apiKeyLast4: aiProviders.apiKeyLast4,
|
||||
isDefault: aiProviders.isDefault,
|
||||
})
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.id, id))
|
||||
.limit(1)
|
||||
if (!existing) return { success: false, message: "AI provider not found" }
|
||||
|
||||
const nextKey = payload.apiKey?.trim()
|
||||
const encrypted = nextKey ? encryptAiApiKey(nextKey) : existing.apiKeyEncrypted
|
||||
const last4 = nextKey ? nextKey.slice(-4) : existing.apiKeyLast4
|
||||
|
||||
const nextIsDefault =
|
||||
payload.isDefault === false && existing.isDefault && defaultCount <= 1 ? true : payload.isDefault ?? existing.isDefault
|
||||
const isNextDefault =
|
||||
payload.isDefault === false && existing.isDefault && defaultCount <= 1
|
||||
? true
|
||||
: payload.isDefault ?? existing.isDefault
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
if (payload.isDefault) {
|
||||
await tx.update(aiProviders).set({ isDefault: false }).where(eq(aiProviders.isDefault, true))
|
||||
}
|
||||
await tx
|
||||
.update(aiProviders)
|
||||
.set({
|
||||
provider: payload.provider,
|
||||
baseUrl,
|
||||
model: payload.model,
|
||||
apiKeyEncrypted: encrypted,
|
||||
apiKeyLast4: last4,
|
||||
isDefault: nextIsDefault,
|
||||
updatedBy: user.id,
|
||||
})
|
||||
.where(eq(aiProviders.id, id))
|
||||
})
|
||||
await updateAiProvider(
|
||||
id,
|
||||
{
|
||||
provider: payload.provider,
|
||||
baseUrl,
|
||||
model: payload.model,
|
||||
apiKeyEncrypted: encrypted,
|
||||
apiKeyLast4: last4,
|
||||
isDefault: isNextDefault,
|
||||
updatedBy: user.id,
|
||||
},
|
||||
payload.isDefault === true
|
||||
)
|
||||
|
||||
revalidatePath("/settings")
|
||||
return { success: true, message: "AI provider updated", data: id }
|
||||
@@ -149,24 +123,22 @@ export async function upsertAiProviderAction(
|
||||
const id = createId()
|
||||
const encrypted = encryptAiApiKey(payload.apiKey.trim())
|
||||
const last4 = payload.apiKey.trim().slice(-4)
|
||||
const makeDefault = payload.isDefault ?? !hasDefault
|
||||
const shouldMakeDefault = payload.isDefault ?? !hasDefault
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
if (makeDefault) {
|
||||
await tx.update(aiProviders).set({ isDefault: false }).where(eq(aiProviders.isDefault, true))
|
||||
}
|
||||
await tx.insert(aiProviders).values({
|
||||
await createAiProvider(
|
||||
{
|
||||
id,
|
||||
provider: payload.provider,
|
||||
baseUrl,
|
||||
model: payload.model,
|
||||
apiKeyEncrypted: encrypted,
|
||||
apiKeyLast4: last4,
|
||||
isDefault: makeDefault,
|
||||
isDefault: shouldMakeDefault,
|
||||
createdBy: user.id,
|
||||
updatedBy: user.id,
|
||||
})
|
||||
})
|
||||
},
|
||||
shouldMakeDefault
|
||||
)
|
||||
|
||||
revalidatePath("/settings")
|
||||
return { success: true, message: "AI provider created", data: id }
|
||||
|
||||
@@ -47,14 +47,18 @@ export function ProfileSettingsForm({ user }: { user: UserProfile }) {
|
||||
function onSubmit(data: ProfileFormValues) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await updateUserProfile({
|
||||
const result = await updateUserProfile({
|
||||
name: data.name,
|
||||
phone: data.phone || undefined,
|
||||
address: data.address || undefined,
|
||||
gender: data.gender || undefined,
|
||||
age: data.age || undefined,
|
||||
})
|
||||
toast.success("Profile updated successfully")
|
||||
if (result.success) {
|
||||
toast.success("Profile updated successfully")
|
||||
} else {
|
||||
toast.error(result.message || "Failed to update profile")
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to update profile")
|
||||
console.error(error)
|
||||
|
||||
172
src/modules/settings/data-access.ts
Normal file
172
src/modules/settings/data-access.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import "server-only"
|
||||
|
||||
import { count, desc, eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { aiProviders, passwordSecurity, users } from "@/shared/db/schema"
|
||||
|
||||
import type { AiProviderExisting, AiProviderName, AiProviderSummary } from "./types"
|
||||
|
||||
// --- AI Provider operations ---
|
||||
|
||||
export async function getAiProviderSummaries(): Promise<AiProviderSummary[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: aiProviders.id,
|
||||
provider: aiProviders.provider,
|
||||
baseUrl: aiProviders.baseUrl,
|
||||
model: aiProviders.model,
|
||||
apiKeyLast4: aiProviders.apiKeyLast4,
|
||||
isDefault: aiProviders.isDefault,
|
||||
updatedAt: aiProviders.updatedAt,
|
||||
})
|
||||
.from(aiProviders)
|
||||
.orderBy(desc(aiProviders.updatedAt))
|
||||
return rows
|
||||
}
|
||||
|
||||
export async function countDefaultAiProviders(): Promise<number> {
|
||||
const [row] = await db
|
||||
.select({ value: count() })
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.isDefault, true))
|
||||
return Number(row?.value ?? 0)
|
||||
}
|
||||
|
||||
export async function getAiProviderForUpdate(id: string): Promise<AiProviderExisting | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: aiProviders.id,
|
||||
apiKeyEncrypted: aiProviders.apiKeyEncrypted,
|
||||
apiKeyLast4: aiProviders.apiKeyLast4,
|
||||
isDefault: aiProviders.isDefault,
|
||||
})
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.id, id))
|
||||
.limit(1)
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
export async function updateAiProvider(
|
||||
id: string,
|
||||
data: {
|
||||
provider: AiProviderName
|
||||
baseUrl: string | null
|
||||
model: string
|
||||
apiKeyEncrypted: string
|
||||
apiKeyLast4: string | null
|
||||
isDefault: boolean
|
||||
updatedBy: string
|
||||
},
|
||||
resetOtherDefaults: boolean
|
||||
): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
if (resetOtherDefaults) {
|
||||
await tx.update(aiProviders).set({ isDefault: false }).where(eq(aiProviders.isDefault, true))
|
||||
}
|
||||
await tx
|
||||
.update(aiProviders)
|
||||
.set({
|
||||
provider: data.provider,
|
||||
baseUrl: data.baseUrl,
|
||||
model: data.model,
|
||||
apiKeyEncrypted: data.apiKeyEncrypted,
|
||||
apiKeyLast4: data.apiKeyLast4,
|
||||
isDefault: data.isDefault,
|
||||
updatedBy: data.updatedBy,
|
||||
})
|
||||
.where(eq(aiProviders.id, id))
|
||||
})
|
||||
}
|
||||
|
||||
export async function createAiProvider(
|
||||
data: {
|
||||
id: string
|
||||
provider: AiProviderName
|
||||
baseUrl: string | null
|
||||
model: string
|
||||
apiKeyEncrypted: string
|
||||
apiKeyLast4: string | null
|
||||
isDefault: boolean
|
||||
createdBy: string
|
||||
updatedBy: string
|
||||
},
|
||||
resetOtherDefaults: boolean
|
||||
): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
if (resetOtherDefaults) {
|
||||
await tx.update(aiProviders).set({ isDefault: false }).where(eq(aiProviders.isDefault, true))
|
||||
}
|
||||
await tx.insert(aiProviders).values({
|
||||
id: data.id,
|
||||
provider: data.provider,
|
||||
baseUrl: data.baseUrl,
|
||||
model: data.model,
|
||||
apiKeyEncrypted: data.apiKeyEncrypted,
|
||||
apiKeyLast4: data.apiKeyLast4,
|
||||
isDefault: data.isDefault,
|
||||
createdBy: data.createdBy,
|
||||
updatedBy: data.updatedBy,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// --- Password change operations ---
|
||||
|
||||
export async function getUserPasswordHash(
|
||||
userId: string
|
||||
): Promise<{ password: string | null } | null> {
|
||||
const [row] = await db
|
||||
.select({ password: users.password })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1)
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
export async function getPasswordSecurityByUserId(
|
||||
userId: string
|
||||
): Promise<{ id: string } | null> {
|
||||
const [row] = await db
|
||||
.select({ id: passwordSecurity.id })
|
||||
.from(passwordSecurity)
|
||||
.where(eq(passwordSecurity.userId, userId))
|
||||
.limit(1)
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
export async function updateUserPassword(
|
||||
userId: string,
|
||||
newHash: string,
|
||||
now: Date
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ password: newHash, updatedAt: now })
|
||||
.where(eq(users.id, userId))
|
||||
}
|
||||
|
||||
export async function upsertPasswordSecurityOnPasswordChange(
|
||||
userId: string,
|
||||
now: Date,
|
||||
existing: { id: string } | null
|
||||
): Promise<void> {
|
||||
if (existing) {
|
||||
await db
|
||||
.update(passwordSecurity)
|
||||
.set({
|
||||
lastPasswordChange: now,
|
||||
passwordChangedAt: now,
|
||||
mustChangePassword: false,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(passwordSecurity.userId, userId))
|
||||
} else {
|
||||
await db.insert(passwordSecurity).values({
|
||||
userId,
|
||||
lastPasswordChange: now,
|
||||
passwordChangedAt: now,
|
||||
mustChangePassword: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
18
src/modules/settings/types.ts
Normal file
18
src/modules/settings/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type AiProviderName = "zhipu" | "openai" | "gemini" | "custom"
|
||||
|
||||
export interface AiProviderSummary {
|
||||
id: string
|
||||
provider: AiProviderName
|
||||
baseUrl: string | null
|
||||
model: string
|
||||
apiKeyLast4: string | null
|
||||
isDefault: boolean
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export interface AiProviderExisting {
|
||||
id: string
|
||||
apiKeyEncrypted: string
|
||||
apiKeyLast4: string | null
|
||||
isDefault: boolean
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user