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:
SpecialX
2026-06-19 05:13:09 +08:00
parent 063baffe4c
commit 49291fcc31
114 changed files with 12548 additions and 3395 deletions

View 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 | 角色选择 | rolestudent/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 Monorepoturborepo / 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 # completeOnboardingActionServer Action + requirePermission
├─ data-access.ts # 仅操作 users.onboardedAt
├─ schema.ts # Zodname/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**:暂不实现家长绑定,由管理员后台预绑定。
### Q4onboarding 路由形态
- **方案 A**(推荐):单页 `/onboarding` + 客户端 stepper步骤状态用 query param 持久化)。
- **方案 B**:嵌套路由 `/onboarding/role``/onboarding/profile``/onboarding/binding`(每步独立 Server Action
- **方案 C**:保留全局 Dialog仅修复安全与架构问题。
### Q5实施范围
- **方案 A**:一次性完成 P0 + P1 + P2 全部整改。
- **方案 B**:先做 P0安全/越权)+ P1架构P2UX后续迭代。
- **方案 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
View 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

File diff suppressed because it is too large Load Diff

960
bugs/others_bug.md Normal file
View 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-MD03URL 参数未编码
- **位置**`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-NPF01Switch 与隐藏 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-AS01Tab 图标语义错误
- **位置**`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 后权限相关 UICompose、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-01announcements 模块未记录页面缺少权限校验
- **位置**004 文档 2.16 节
- **问题**:已记录 `getAnnouncementsAction` 使用 `requireAuth()` 而非 `requirePermission()`,但未记录 `app/(dashboard)/announcements/page.tsx` 完全缺少权限校验
- **改进建议**:补充已知问题「⚠️ P2`app/(dashboard)/announcements/page.tsx` 完全缺少权限校验」
#### DOC-02management 模块未在架构文档中独立记录
- **位置**004 文档
- **问题**`app/(dashboard)/management/grade/` 路由未在架构文档中记录其依赖关系
- **改进建议**:补充 management 路由的模块依赖classes、school
#### DOC-03settings 模块文件清单过期
- **位置**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 AgentGLM-5.2
> 核查方法:人工逐行审查 + 架构图比对 + 技能规则匹配
> 应用技能:`vercel-react-best-practices`65 条规则)、`web-design-guidelines`Web Interface Guidelines
> 注:`web-artifacts-builder` 技能加载失败,界面优化建议已合并至第四章

551
bugs/parent_bug.md Normal file
View 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-P001app 层直接访问 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-P016Link 缺少 `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 列布局在 sm640px下更合适
- **改进建议**`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` 在 md768-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-P01004 文档 parent 模块行数记录过期
- **位置**`docs/architecture/004_architecture_impact_map.md:924`
- **问题**:记录 `data-access.ts | 234 | 子女关系 + 仪表盘数据聚合`,实际 234 行 ✅ 一致;但 `components/* | 7 文件` 实际为 7 个组件文件 ✅ 一致
- **说明**:本节核查后无需更新(行数与文件数均一致)
### DOC-P02004 文档未记录 `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-P03005 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
View 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-A01Prettier 配置违规(使用分号)
- **位置**`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-01004 文件行数记录过期
- **位置**`docs/architecture/004_architecture_impact_map.md:408`
- **问题**:记录 `types/permissions.ts | 92 | 54 个权限点常量`,实际文件 114 行,含 `DataScope`、`AuthContext` 类型定义
- **改进建议**:更新为 `114 行 | 54 个权限点 + DataScope + AuthContext`
### DOC-02005 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 AgentGLM-5.2
> 核查方法:人工逐行审查 + 架构图比对 + 技能规则匹配

711
bugs/student_bug.md Normal file
View 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-L03JSX 语法格式错误
- **位置**`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-C01catch 块吞掉错误
- **位置**`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-01005 JSON 中 `/student/grades` 的 dataAccess 记录错误
- **位置**`docs/architecture/005_architecture_data.json:12047`
- **问题**:记录为 `"grades/actions.getStudentGradeSummaryAction"`,实际代码调用 `grades/data-access.getStudentGradeSummary`
- **改进建议**:修正为 `"grades/data-access.getStudentGradeSummary"`
### DOC-02005 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-03005 JSON 中 `/student/diagnostic` 的 type 错误
- **位置**`docs/architecture/005_architecture_data.json:12063`
- **问题**:记录 `"type": "client"`,实际 `page.tsx` 是 Server Component
- **改进建议**:改为 `"type": "server"`,并注明内部 `StudentDiagnosticView` 组件为 client
### DOC-04005 JSON 中 `/student/dashboard` 缺少 `getStudentSchedule` 记录
- **位置**`docs/architecture/005_architecture_data.json:11978-11982`
- **问题**:实际代码调用了 `getStudentSchedule(student.id)`,但 dataAccess 数组未记录
- **改进建议**:补充 `"classes/data-access.getStudentSchedule"`
### DOC-05005 JSON 中 `/student/learning/assignments` 缺少 `getDemoStudentUser` 记录
- **位置**`docs/architecture/005_architecture_data.json:11989-11991`
- **问题**:实际代码调用了 `getDemoStudentUser()`,但 dataAccess 数组未记录
- **改进建议**:补充 `"homework/data-access.getDemoStudentUser"`(或迁移后更新为 `users/data-access.getCurrentStudentUser`
### DOC-06004 文档 2.26 节未记录 `loading.tsx` 文件
- **位置**`docs/architecture/004_architecture_impact_map.md:1109-1114`
- **问题**:文件清单仅列出 3 个组件,未记录 `app/(dashboard)/student/*/loading.tsx`
- **改进建议**:补充 loading.tsx 文件清单
### DOC-07004 文档未记录 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 AgentGLM-5.2
> 核查方法:人工逐行审查 + 架构图比对 + 技能规则匹配
> 应用技能:`vercel-react-best-practices`(性能优化)、`web-artifacts-builder`(界面构建参考)、`web-design-guidelines`(界面规范审查)

741
bugs/teacher_bug.md Normal file
View 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-T01app 层直接访问数据库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-T02app 层直接访问数据库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-T03app 层直接访问数据库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-T04app 层直接访问数据库grades/entry/page.tsx
- **位置**[grades/entry/page.tsx:1-3, 25](../src/app/(dashboard)/teacher/grades/entry/page.tsx)
- **问题**:同 BUG-T02
- **改进建议**:同 BUG-T02
#### BUG-T05app 层直接访问数据库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-T07textbooks/page.tsx 使用分号
- **位置**[textbooks/page.tsx:3, 73](../src/app/(dashboard)/teacher/textbooks/page.tsx)
- **问题**`import { TextbookCard } from "...";` 等多处使用分号
- **改进建议**:运行 `npx prettier --write` 统一格式
#### BUG-T08textbooks/[id]/page.tsx 使用分号
- **位置**[textbooks/[id]/page.tsx](../src/app/(dashboard)/teacher/textbooks/[id]/page.tsx)(全文)
- **问题**:多处语句使用分号结尾
- **改进建议**:同 BUG-T07
#### BUG-T09textbooks/loading.tsx 使用分号
- **位置**[textbooks/loading.tsx](../src/app/(dashboard)/teacher/textbooks/loading.tsx)(全文)
- **问题**:同 BUG-T07
- **改进建议**:同 BUG-T07
#### BUG-T10textbooks/[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串行数据获取 waterfallattendance/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串行数据获取 waterfallattendance/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串行数据获取 waterfallattendance/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串行数据获取 waterfallgrades/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串行数据获取 waterfallgrades/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串行数据获取 waterfallgrades/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串行数据获取 waterfallclasses/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串行数据获取 waterfalldiagnostic/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串行数据获取 waterfallexams/[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-T29Bundle 优化 - barrel importslucide-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-T39Flex 子元素缺少 `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-T46exams/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-T52exams/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-T53homework/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-T54exams/[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-T55exams/[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-T56grades/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-T57exams/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-T61homework/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-T62textbooks/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-T63exams/create/page.tsx 缺少页面标题
- **位置**[exams/create/page.tsx:3-9](../src/app/(dashboard)/teacher/exams/create/page.tsx)
- **问题**:页面无任何标题,直接渲染表单
- **改进建议**:添加 `<h1>Create Exam</h1>`
#### BUG-T64loading.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 顺序修复,优先解决架构与安全问题。

View 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

View File

@@ -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 保持向后兼容。 > ✅ `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 职责混乱 — 混入三个外部业务领域(拆分后仍存在于子文件中) #### 2.2.1 职责混乱 — 混入三个外部业务领域(拆分后仍存在于子文件中)
`data-access-*.ts` 文件群仍承载了四个业务领域的逻辑(已按职责分文件,但跨域逻辑尚未迁移回所属模块 `data-access-*.ts` 文件群仍承载了四个业务领域的逻辑(已按职责分文件,homework 跨域查询已通过 data-access-classes 封装
| 文件 | 逻辑 | 应属模块 | | 文件 | 逻辑 | 应属模块 |
|------|------|---------| |------|------|---------|
| data-access.ts | 教师身份解析、班级访问控制、班级 CRUD | classes合理 | | data-access.ts | 教师身份解析、班级访问控制、班级 CRUD | classes合理 |
| data-access-students.ts | 班级学生查询 | 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-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 修复): **关键问题**P1-1 部分已修复):
- `getClassHomeworkInsights``getGradeHomeworkInsights` 直接查询 `homeworkAssignments``homeworkSubmissions``homeworkAssignmentTargets``homeworkAssignmentQuestions``exams` 五张表,属于 homework 模块的核心业务,不应存在于 classes 模块 - ✅ 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 侧的写函数仍存在(待后续迁移)。 - 课表 CRUD`createClassScheduleItem` / `updateClassScheduleItem` / `deleteClassScheduleItem`)写入 `classSchedule`P0-6 已统一 scheduling/data-access 为写入口,但 classes 侧的写函数仍存在(待后续迁移)。
- `getStudentsSubjectScores` 直接关联 `homeworkSubmissions` + `exams` + `subjects` 计算学生科目分数,属于成绩分析逻辑。
#### 2.2.2 types.ts 跨领域类型污染 #### 2.2.2 types.ts 跨领域类型污染

View File

@@ -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 { getAnnouncements } from "@/modules/announcements/data-access"
import { AnnouncementList } from "@/modules/announcements/components/announcement-list" import { AnnouncementList } from "@/modules/announcements/components/announcement-list"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default async function AnnouncementsPage() { export default async function AnnouncementsPage() {
await requirePermission(Permissions.ANNOUNCEMENT_READ)
const announcements = await getAnnouncements({ status: "published" }) const announcements = await getAnnouncements({ status: "published" })
return ( return (

View File

@@ -1,6 +1,5 @@
import { redirect } from "next/navigation" import { redirect } from "next/navigation"
import { auth } from "@/auth" import { auth } from "@/auth"
import { Permissions } from "@/shared/types/permissions"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -8,11 +7,10 @@ export default async function DashboardPage() {
const session = await auth() const session = await auth()
if (!session?.user) redirect("/login") if (!session?.user) redirect("/login")
const permissions = session.user.permissions ?? []
const roles = session.user.roles ?? [] const roles = session.user.roles ?? []
if (permissions.includes(Permissions.SCHOOL_MANAGE)) redirect("/admin/dashboard") if (roles.includes("admin")) redirect("/admin/dashboard")
if (permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)) redirect("/student/dashboard") if (roles.includes("student")) redirect("/student/dashboard")
if (roles.includes("parent")) redirect("/parent/dashboard") if (roles.includes("parent")) redirect("/parent/dashboard")
redirect("/teacher/dashboard") redirect("/teacher/dashboard")
} }

View File

@@ -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 { getGradeManagedClasses, getManagedGrades, getTeacherOptions } from "@/modules/classes/data-access"
import { GradeClassesClient } from "@/modules/classes/components/grade-classes-view" import { GradeClassesClient } from "@/modules/classes/components/grade-classes-view"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default async function GradeClassesPage() { export default async function GradeClassesPage() {
const session = await auth() const ctx = await requirePermission(Permissions.GRADE_MANAGE)
const userId = session?.user?.id ?? "" const userId = ctx.userId
const [classes, teachers, managedGrades] = await Promise.all([ const [classes, teachers, managedGrades] = await Promise.all([
getGradeManagedClasses(userId), getGradeManagedClasses(userId),

View File

@@ -1,3 +1,4 @@
import { requireAuth } from "@/shared/lib/auth-guard"
import { getTeacherIdForMutations } from "@/modules/classes/data-access" import { getTeacherIdForMutations } from "@/modules/classes/data-access"
import { getGradeHomeworkInsights } from "@/modules/classes/data-access" import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
import { getGradesForStaff } from "@/modules/school/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) : "-") 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> }) { export default async function TeacherGradeInsightsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
await requireAuth()
const params = await searchParams const params = await searchParams
const gradeId = getParam(params, "gradeId") const gradeId = getParam(params, "gradeId")

View File

@@ -1,7 +1,7 @@
import Link from "next/link" import Link from "next/link"
import { redirect } from "next/navigation" 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 { getStudentClasses, getStudentSchedule, getTeacherClasses, getTeacherTeachingSubjects } from "@/modules/classes/data-access"
import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card" import { StudentGradesCard } from "@/modules/dashboard/components/student-dashboard/student-grades-card"
import { StudentStatsGrid } from "@/modules/dashboard/components/student-dashboard/student-stats-grid" 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() { export default async function ProfilePage() {
const session = await auth() const ctx = await requireAuth()
if (!session?.user) redirect("/login")
const userId = String(session.user.id ?? "").trim() const userId = ctx.userId
const userProfile = await getUserProfile(userId) const userProfile = await getUserProfile(userId)
if (!userProfile) { if (!userProfile) {
redirect("/login") redirect("/login")
} }
const permissions = session.user.permissions ?? [] const permissions = ctx.permissions
const isStudent = permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE) const isStudent = permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE)
const isTeacher = permissions.includes(Permissions.EXAM_CREATE) const isTeacher = permissions.includes(Permissions.EXAM_CREATE)

View File

@@ -1,6 +1,6 @@
import { redirect } from "next/navigation" 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 { AdminSettingsView } from "@/modules/settings/components/admin-settings-view"
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view" import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
import { TeacherSettingsView } from "@/modules/settings/components/teacher-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 const dynamic = "force-dynamic"
export default async function SettingsPage() { export default async function SettingsPage() {
const session = await auth() const ctx = await requireAuth()
if (!session?.user) redirect("/login")
const userId = String(session.user.id ?? "").trim() const userId = ctx.userId
const userProfile = await getUserProfile(userId) const userProfile = await getUserProfile(userId)
if (!userProfile) redirect("/login") if (!userProfile) redirect("/login")
const permissions = session.user.permissions ?? [] const permissions = ctx.permissions
const notificationPrefs = await getNotificationPreferences(userId) const notificationPrefs = await getNotificationPreferences(userId)
if (permissions.includes(Permissions.SETTINGS_ADMIN)) { if (permissions.includes(Permissions.SETTINGS_ADMIN)) {

View File

@@ -1,7 +1,6 @@
import { redirect } from "next/navigation"
import { Lock } from "lucide-react" 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 { PasswordChangeForm } from "@/modules/settings/components/password-change-form"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -12,8 +11,7 @@ export const metadata = {
} }
export default async function SecuritySettingsPage() { export default async function SecuritySettingsPage() {
const session = await auth() await requireAuth()
if (!session?.user) redirect("/login")
return ( return (
<div className="flex h-full flex-col gap-8 p-8"> <div className="flex h-full flex-col gap-8 p-8">

View File

@@ -1,6 +1,7 @@
import { StudentDashboard } from "@/modules/dashboard/components/student-dashboard/student-dashboard-view" import { StudentDashboard } from "@/modules/dashboard/components/student-dashboard/student-dashboard-view"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access" 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 { EmptyState } from "@/shared/components/ui/empty-state"
import { Inbox } from "lucide-react" 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() { export default async function StudentDashboardPage() {
const student = await getDemoStudentUser() const student = await getCurrentStudentUser()
if (!student) { if (!student) {
return ( return (
<div className="flex h-full flex-col items-center justify-center"> <div className="flex h-full flex-col items-center justify-center">

View File

@@ -1,31 +1,13 @@
import { auth } from "@/auth" import { getAuthContext } from "@/shared/lib/auth-guard"
import { Inbox } from "lucide-react"
import { getAvailableCoursesForStudent, getStudentSelections } from "@/modules/elective/data-access-selections" import { getAvailableCoursesForStudent, getStudentSelections } from "@/modules/elective/data-access-selections"
import { StudentSelectionView } from "@/modules/elective/components/student-selection-view" import { StudentSelectionView } from "@/modules/elective/components/student-selection-view"
import { EmptyState } from "@/shared/components/ui/empty-state"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default async function StudentElectivePage() { export default async function StudentElectivePage() {
const session = await auth() const ctx = await getAuthContext()
const studentId = String(session?.user?.id ?? "") const studentId = ctx.userId
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 [availableCourses, mySelections] = await Promise.all([ const [availableCourses, mySelections] = await Promise.all([
getAvailableCoursesForStudent(studentId), getAvailableCoursesForStudent(studentId),

View File

@@ -1,6 +1,7 @@
import { notFound } from "next/navigation" 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 { HomeworkTakeView } from "@/modules/homework/components/homework-take-view"
import { HomeworkReviewView } from "@/modules/homework/components/student-homework-review-view" import { HomeworkReviewView } from "@/modules/homework/components/student-homework-review-view"
import { formatDate } from "@/shared/lib/utils" import { formatDate } from "@/shared/lib/utils"
@@ -13,7 +14,7 @@ export default async function StudentAssignmentTakePage({
params: Promise<{ assignmentId: string }> params: Promise<{ assignmentId: string }>
}) { }) {
const { assignmentId } = await params const { assignmentId } = await params
const student = await getDemoStudentUser() const student = await getCurrentStudentUser()
if (!student) return notFound() if (!student) return notFound()
const data = await getStudentHomeworkTakeData(assignmentId, student.id) const data = await getStudentHomeworkTakeData(assignmentId, student.id)

View File

@@ -5,7 +5,8 @@ import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { formatDate } from "@/shared/lib/utils" 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" import { Inbox } from "lucide-react"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -39,7 +40,7 @@ const getActionVariant = (status: string): "default" | "secondary" | "outline" =
const isAnswered = (status: string) => status === "submitted" || status === "graded" const isAnswered = (status: string) => status === "submitted" || status === "graded"
export default async function StudentAssignmentsPage() { export default async function StudentAssignmentsPage() {
const student = await getDemoStudentUser() const student = await getCurrentStudentUser()
if (!student) { if (!student) {
return ( return (

View File

@@ -1,14 +1,14 @@
import { Inbox } from "lucide-react" import { Inbox } from "lucide-react"
import { getStudentClasses } from "@/modules/classes/data-access" 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 { StudentCoursesView } from "@/modules/student/components/student-courses-view"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default async function StudentCoursesPage() { export default async function StudentCoursesPage() {
const student = await getDemoStudentUser() const student = await getCurrentStudentUser()
if (!student) { if (!student) {
return ( return (
<div className="flex h-full flex-col space-y-8 p-8"> <div className="flex h-full flex-col space-y-8 p-8">

View File

@@ -6,7 +6,7 @@ import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookI
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader" import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state" 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" export const dynamic = "force-dynamic"
@@ -15,7 +15,7 @@ export default async function StudentTextbookDetailPage({
}: { }: {
params: Promise<{ id: string }> params: Promise<{ id: string }>
}) { }) {
const student = await getDemoStudentUser() const student = await getCurrentStudentUser()
if (!student) { if (!student) {
return ( return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex"> <div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">

View File

@@ -3,7 +3,7 @@ import { BookOpen, Inbox } from "lucide-react"
import { getTextbooks } from "@/modules/textbooks/data-access" import { getTextbooks } from "@/modules/textbooks/data-access"
import { TextbookCard } from "@/modules/textbooks/components/textbook-card" import { TextbookCard } from "@/modules/textbooks/components/textbook-card"
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters" 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" import { EmptyState } from "@/shared/components/ui/empty-state"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
@@ -20,7 +20,7 @@ export default async function StudentTextbooksPage({
}: { }: {
searchParams: Promise<SearchParams> searchParams: Promise<SearchParams>
}) { }) {
const [student, sp] = await Promise.all([getDemoStudentUser(), searchParams]) const [student, sp] = await Promise.all([getCurrentStudentUser(), searchParams])
if (!student) { if (!student) {
return ( return (

View File

@@ -1,7 +1,7 @@
import { Inbox } from "lucide-react" import { Inbox } from "lucide-react"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access" 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 { StudentScheduleFilters } from "@/modules/student/components/student-schedule-filters"
import { StudentScheduleView } from "@/modules/student/components/student-schedule-view" import { StudentScheduleView } from "@/modules/student/components/student-schedule-view"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
@@ -15,7 +15,7 @@ export default async function StudentSchedulePage({
}: { }: {
searchParams: Promise<SearchParams> searchParams: Promise<SearchParams>
}) { }) {
const student = await getDemoStudentUser() const student = await getCurrentStudentUser()
if (!student) { if (!student) {
return ( return (
<div className="flex h-full flex-col space-y-8 p-8"> <div className="flex h-full flex-col space-y-8 p-8">

View File

@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { createId } from "@paralleldrive/cuid2" 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 { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state" import type { ActionState } from "@/shared/types/action-state"
@@ -218,7 +218,7 @@ export async function getAnnouncementsAction(
params?: GetAnnouncementsParams params?: GetAnnouncementsParams
): Promise<ActionState<Announcement[]>> { ): Promise<ActionState<Announcement[]>> {
try { try {
await requireAuth() await requirePermission(Permissions.ANNOUNCEMENT_READ)
const data = await getAnnouncements(params) const data = await getAnnouncements(params)
return { success: true, data } return { success: true, data }
} catch (e) { } catch (e) {

View File

@@ -8,8 +8,6 @@ import { announcements, users } from "@/shared/db/schema"
import type { import type {
Announcement, Announcement,
AnnouncementInsertData, AnnouncementInsertData,
AnnouncementStatus,
AnnouncementType,
AnnouncementUpdateData, AnnouncementUpdateData,
GetAnnouncementsParams, GetAnnouncementsParams,
} from "./types" } from "./types"
@@ -17,6 +15,8 @@ import type {
const toIso = (d: Date | null | undefined): string | null => const toIso = (d: Date | null | undefined): string | null =>
d ? d.toISOString() : null d ? d.toISOString() : null
const toIsoRequired = (d: Date): string => d.toISOString()
const mapRow = ( const mapRow = (
row: { row: {
id: string id: string
@@ -43,8 +43,8 @@ const mapRow = (
authorId: row.authorId, authorId: row.authorId,
authorName: row.authorName, authorName: row.authorName,
publishedAt: toIso(row.publishedAt), publishedAt: toIso(row.publishedAt),
createdAt: toIso(row.createdAt) as string, createdAt: toIsoRequired(row.createdAt),
updatedAt: toIso(row.updatedAt) as string, updatedAt: toIsoRequired(row.updatedAt),
}) })
export const getAnnouncements = cache( export const getAnnouncements = cache(
@@ -56,10 +56,10 @@ export const getAnnouncements = cache(
const conditions = [] const conditions = []
if (params?.status) { if (params?.status) {
conditions.push(eq(announcements.status, params.status as AnnouncementStatus)) conditions.push(eq(announcements.status, params.status))
} }
if (params?.type) { if (params?.type) {
conditions.push(eq(announcements.type, params.type as AnnouncementType)) conditions.push(eq(announcements.type, params.type))
} }
const rows = await db const rows = await db
@@ -85,7 +85,8 @@ export const getAnnouncements = cache(
.offset(offset) .offset(offset)
return rows.map(mapRow) return rows.map(mapRow)
} catch { } catch (error) {
console.error("getAnnouncements failed:", error)
return [] return []
} }
} }
@@ -115,7 +116,8 @@ export const getAnnouncementById = cache(
.limit(1) .limit(1)
return row ? mapRow(row) : null return row ? mapRow(row) : null
} catch { } catch (error) {
console.error("getAnnouncementById failed:", error)
return null return null
} }
} }

View File

@@ -43,6 +43,9 @@ const STATUS_OPTIONS: AttendanceStatus[] = [
"excused", "excused",
] ]
const isAttendanceStatus = (v: string): v is AttendanceStatus =>
v === "present" || v === "absent" || v === "late" || v === "early_leave" || v === "excused"
function SubmitButton() { function SubmitButton() {
const { pending } = useFormStatus() const { pending } = useFormStatus()
return ( return (
@@ -180,7 +183,11 @@ export function AttendanceSheet({
<TableCell> <TableCell>
<Select <Select
value={statuses[s.id] ?? "present"} 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"> <SelectTrigger className="h-8">
<SelectValue /> <SelectValue />

View File

@@ -192,7 +192,7 @@ export async function updateAttendanceRecord(
id: string, id: string,
data: UpdateAttendanceInput data: UpdateAttendanceInput
): Promise<void> { ): 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.status !== undefined) update.status = data.status
if (data.remark !== undefined) update.remark = data.remark if (data.remark !== undefined) update.remark = data.remark
if (data.scheduleId !== undefined) update.scheduleId = data.scheduleId if (data.scheduleId !== undefined) update.scheduleId = data.scheduleId

View File

@@ -15,17 +15,17 @@ import type {
PaginatedResult, PaginatedResult,
} from "./types" } from "./types"
const toIso = (d: Date) => d.toISOString() const toIso = (d: Date): string => d.toISOString()
const DEFAULT_PAGE_SIZE = 20 const DEFAULT_PAGE_SIZE = 20
const MAX_PAGE_SIZE = 100 const MAX_PAGE_SIZE = 100
const clampPageSize = (size?: number) => { const clampPageSize = (size?: number): number => {
if (!size || size <= 0) return DEFAULT_PAGE_SIZE if (!size || size <= 0) return DEFAULT_PAGE_SIZE
return Math.min(size, MAX_PAGE_SIZE) return Math.min(size, MAX_PAGE_SIZE)
} }
const clampPage = (page?: number) => { const clampPage = (page?: number): number => {
if (!page || page <= 0) return 1 if (!page || page <= 0) return 1
return page return page
} }
@@ -72,7 +72,7 @@ export async function getAuditLogs(
detail: r.detail ?? null, detail: r.detail ?? null,
ipAddress: r.ipAddress ?? null, ipAddress: r.ipAddress ?? null,
userAgent: r.userAgent ?? null, userAgent: r.userAgent ?? null,
status: r.status as "success" | "failure", status: r.status,
createdAt: toIso(r.createdAt), createdAt: toIso(r.createdAt),
})), })),
total, total,
@@ -80,7 +80,8 @@ export async function getAuditLogs(
pageSize, pageSize,
totalPages: Math.ceil(total / pageSize), totalPages: Math.ceil(total / pageSize),
} }
} catch { } catch (error) {
console.error("getAuditLogs failed:", error)
return { items: [], total: 0, page, pageSize, totalPages: 0 } return { items: [], total: 0, page, pageSize, totalPages: 0 }
} }
} }
@@ -119,8 +120,8 @@ export async function getLoginLogs(
id: r.id, id: r.id,
userId: r.userId ?? null, userId: r.userId ?? null,
userEmail: r.userEmail, userEmail: r.userEmail,
action: r.action as "signin" | "signout" | "signup", action: r.action,
status: r.status as "success" | "failure", status: r.status,
ipAddress: r.ipAddress ?? null, ipAddress: r.ipAddress ?? null,
userAgent: r.userAgent ?? null, userAgent: r.userAgent ?? null,
errorMessage: r.errorMessage ?? null, errorMessage: r.errorMessage ?? null,
@@ -131,7 +132,8 @@ export async function getLoginLogs(
pageSize, pageSize,
totalPages: Math.ceil(total / pageSize), totalPages: Math.ceil(total / pageSize),
} }
} catch { } catch (error) {
console.error("getLoginLogs failed:", error)
return { items: [], total: 0, page, pageSize, totalPages: 0 } return { items: [], total: 0, page, pageSize, totalPages: 0 }
} }
} }
@@ -143,7 +145,8 @@ export async function getAuditModuleOptions(): Promise<string[]> {
.from(auditLogs) .from(auditLogs)
.orderBy(asc(auditLogs.module)) .orderBy(asc(auditLogs.module))
return rows.map((r) => r.module) return rows.map((r) => r.module)
} catch { } catch (error) {
console.error("getAuditModuleOptions failed:", error)
return [] return []
} }
} }
@@ -183,7 +186,7 @@ export async function getDataChangeLogs(
id: r.id, id: r.id,
tableName: r.tableName, tableName: r.tableName,
recordId: r.recordId, recordId: r.recordId,
action: r.action as "create" | "update" | "delete", action: r.action,
oldValue: r.oldValue ?? null, oldValue: r.oldValue ?? null,
newValue: r.newValue ?? null, newValue: r.newValue ?? null,
changedBy: r.changedBy, changedBy: r.changedBy,
@@ -196,7 +199,8 @@ export async function getDataChangeLogs(
pageSize, pageSize,
totalPages: Math.ceil(total / pageSize), totalPages: Math.ceil(total / pageSize),
} }
} catch { } catch (error) {
console.error("getDataChangeLogs failed:", error)
return { items: [], total: 0, page, pageSize, totalPages: 0 } return { items: [], total: 0, page, pageSize, totalPages: 0 }
} }
} }
@@ -212,7 +216,8 @@ export async function getDataChangeStats(): Promise<DataChangeStat[]> {
.groupBy(dataChangeLogs.tableName) .groupBy(dataChangeLogs.tableName)
.orderBy(desc(count())) .orderBy(desc(count()))
return rows.map((r) => ({ tableName: r.tableName, count: Number(r.count) })) return rows.map((r) => ({ tableName: r.tableName, count: Number(r.count) }))
} catch { } catch (error) {
console.error("getDataChangeStats failed:", error)
return [] return []
} }
} }
@@ -224,7 +229,8 @@ export async function getDataChangeTableOptions(): Promise<string[]> {
.from(dataChangeLogs) .from(dataChangeLogs)
.orderBy(asc(dataChangeLogs.tableName)) .orderBy(asc(dataChangeLogs.tableName))
return rows.map((r) => r.tableName) return rows.map((r) => r.tableName)
} catch { } catch (error) {
console.error("getDataChangeTableOptions failed:", error)
return [] return []
} }
} }
@@ -235,8 +241,16 @@ export async function getDataChangeTableOptions(): Promise<string[]> {
export async function getAuditLogsForExport( export async function getAuditLogsForExport(
params?: AuditLogQueryParams params?: AuditLogQueryParams
): Promise<AuditLog[]> { ): Promise<AuditLog[]> {
const result = await getAuditLogs({ ...params, page: 1, pageSize: 100 }) const items: AuditLog[] = []
return result.items 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( export async function getLoginLogsForExport(
params?: LoginLogQueryParams params?: LoginLogQueryParams
): Promise<LoginLog[]> { ): Promise<LoginLog[]> {
const result = await getLoginLogs({ ...params, page: 1, pageSize: 100 }) const items: LoginLog[] = []
return result.items 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( export async function getDataChangeLogsForExport(
params?: DataChangeLogQueryParams params?: DataChangeLogQueryParams
): Promise<DataChangeLog[]> { ): Promise<DataChangeLog[]> {
const result = await getDataChangeLogs({ ...params, page: 1, pageSize: 100 }) const items: DataChangeLog[] = []
return result.items 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
} }

View File

@@ -1,19 +1,14 @@
"use server"; "use server";
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { and, eq, sql, or } from "drizzle-orm"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions" 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 type { ActionState } from "@/shared/types/action-state"
import { import {
createAdminClass, createAdminClass,
createClassScheduleItem,
createTeacherClass, createTeacherClass,
deleteAdminClass, deleteAdminClass,
deleteClassScheduleItem,
deleteTeacherClass, deleteTeacherClass,
enrollStudentByEmail, enrollStudentByEmail,
enrollStudentByInvitationCode, enrollStudentByInvitationCode,
@@ -23,10 +18,31 @@ import {
setClassSubjectTeachers, setClassSubjectTeachers,
setStudentEnrollmentStatus, setStudentEnrollmentStatus,
updateAdminClass, updateAdminClass,
updateClassScheduleItem,
updateTeacherClass, updateTeacherClass,
getClassGradeId,
} from "./data-access" } 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 { 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) const isClassSubject = (v: string): v is ClassSubject => DEFAULT_CLASS_SUBJECTS.includes(v as ClassSubject)
@@ -37,45 +53,42 @@ export async function createTeacherClassAction(
try { try {
const ctx = await requirePermission(Permissions.CLASS_CREATE) const ctx = await requirePermission(Permissions.CLASS_CREATE)
const schoolName = formData.get("schoolName") const parsed = CreateTeacherClassSchema.safeParse({
const schoolId = formData.get("schoolId") name: formData.get("name"),
const name = formData.get("name") grade: formData.get("grade"),
const grade = formData.get("grade") schoolName: formData.get("schoolName"),
const gradeId = formData.get("gradeId") schoolId: formData.get("schoolId"),
const homeroom = formData.get("homeroom") gradeId: formData.get("gradeId"),
const room = formData.get("room") 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) { const { name, grade, schoolName, schoolId, gradeId, homeroom, room } = parsed.data
return { success: false, message: "Class name is required" }
}
if (typeof grade !== "string" || grade.trim().length === 0) {
return { success: false, message: "Grade is required" }
}
if (!ctx.roles.includes("admin")) { if (!ctx.roles.includes("admin")) {
const userId = ctx.userId const userId = ctx.userId
const normalizedGradeId = typeof gradeId === "string" ? gradeId.trim() : "" const normalizedGradeId = typeof gradeId === "string" ? gradeId.trim() : ""
const normalizedGradeName = grade.trim().toLowerCase() const isOwner = normalizedGradeId
const where = normalizedGradeId ? await isGradeHead(normalizedGradeId, userId)
? and(eq(grades.id, normalizedGradeId), eq(grades.gradeHeadId, userId)) : Boolean(await findGradeIdByHeadAndName(userId, grade))
: and(eq(grades.gradeHeadId, userId), sql`LOWER(${grades.name}) = ${normalizedGradeName}`) if (!isOwner) {
const [ownedGrade] = await db.select({ id: grades.id }).from(grades).where(where).limit(1)
if (!ownedGrade) {
return { success: false, message: "Only admins and grade heads can create classes" } return { success: false, message: "Only admins and grade heads can create classes" }
} }
} }
try { try {
const id = await createTeacherClass({ const id = await createTeacherClass({
schoolName: typeof schoolName === "string" ? schoolName : null, schoolName: schoolName ?? null,
schoolId: typeof schoolId === "string" ? schoolId : null, schoolId: schoolId ?? null,
name, name,
grade, grade,
gradeId: typeof gradeId === "string" ? gradeId : null, gradeId: gradeId ?? null,
homeroom: typeof homeroom === "string" ? homeroom : null, homeroom: homeroom ?? null,
room: typeof room === "string" ? room : null, room: room ?? null,
}) })
revalidatePath("/teacher/classes/my") revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students") revalidatePath("/teacher/classes/students")
@@ -98,27 +111,31 @@ export async function updateTeacherClassAction(
try { try {
await requirePermission(Permissions.CLASS_UPDATE) await requirePermission(Permissions.CLASS_UPDATE)
const schoolName = formData.get("schoolName") const parsed = UpdateTeacherClassSchema.safeParse({
const schoolId = formData.get("schoolId") classId,
const name = formData.get("name") schoolName: formData.get("schoolName"),
const grade = formData.get("grade") schoolId: formData.get("schoolId"),
const gradeId = formData.get("gradeId") name: formData.get("name"),
const homeroom = formData.get("homeroom") grade: formData.get("grade"),
const room = formData.get("room") gradeId: formData.get("gradeId"),
homeroom: formData.get("homeroom"),
if (typeof classId !== "string" || classId.trim().length === 0) { room: formData.get("room"),
})
if (!parsed.success) {
return { success: false, message: "Missing class id" } return { success: false, message: "Missing class id" }
} }
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, homeroom, room } = parsed.data
try { try {
await updateTeacherClass(classId, { await updateTeacherClass(validatedClassId, {
schoolName: typeof schoolName === "string" ? schoolName : undefined, schoolName: schoolName ?? undefined,
schoolId: typeof schoolId === "string" ? schoolId : undefined, schoolId: schoolId ?? undefined,
name: typeof name === "string" ? name : undefined, name: name ?? undefined,
grade: typeof grade === "string" ? grade : undefined, grade: grade ?? undefined,
gradeId: typeof gradeId === "string" ? gradeId : undefined, gradeId: gradeId ?? undefined,
homeroom: typeof homeroom === "string" ? homeroom : undefined, homeroom: homeroom ?? undefined,
room: typeof room === "string" ? room : undefined, room: room ?? undefined,
}) })
revalidatePath("/teacher/classes/my") revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students") revalidatePath("/teacher/classes/students")
@@ -137,12 +154,13 @@ export async function deleteTeacherClassAction(classId: string): Promise<ActionS
try { try {
await requirePermission(Permissions.CLASS_DELETE) 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" } return { success: false, message: "Missing class id" }
} }
try { try {
await deleteTeacherClass(classId) await deleteTeacherClass(parsed.data.classId)
revalidatePath("/teacher/classes/my") revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students") revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/schedule") revalidatePath("/teacher/classes/schedule")
@@ -163,46 +181,38 @@ export async function createGradeClassAction(
try { try {
const ctx = await requirePermission(Permissions.CLASS_CREATE) const ctx = await requirePermission(Permissions.CLASS_CREATE)
const schoolName = formData.get("schoolName") const parsed = CreateGradeClassSchema.safeParse({
const schoolId = formData.get("schoolId") name: formData.get("name"),
const name = formData.get("name") gradeId: formData.get("gradeId"),
const grade = formData.get("grade") teacherId: formData.get("teacherId"),
const gradeId = formData.get("gradeId") schoolName: formData.get("schoolName"),
const teacherId = formData.get("teacherId") schoolId: formData.get("schoolId"),
const homeroom = formData.get("homeroom") grade: formData.get("grade"),
const room = formData.get("room") 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) { const { name, gradeId, teacherId, schoolName, schoolId, grade, homeroom, room } = parsed.data
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" }
}
// Verify access // Verify access
const [managedGrade] = await db const isManager = await isGradeManager(gradeId, ctx.userId)
.select({ id: grades.id }) if (!isManager) {
.from(grades)
.where(and(eq(grades.id, gradeId), or(eq(grades.gradeHeadId, ctx.userId), eq(grades.teachingHeadId, ctx.userId))))
.limit(1)
if (!managedGrade) {
return { success: false, message: "You do not have permission to create classes for this grade" } return { success: false, message: "You do not have permission to create classes for this grade" }
} }
try { try {
const id = await createAdminClass({ const id = await createAdminClass({
schoolName: typeof schoolName === "string" ? schoolName : null, schoolName: schoolName ?? null,
schoolId: typeof schoolId === "string" ? schoolId : null, schoolId: schoolId ?? null,
name, 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, gradeId,
teacherId, teacherId,
homeroom: typeof homeroom === "string" ? homeroom : null, homeroom: homeroom ?? null,
room: typeof room === "string" ? room : null, room: room ?? null,
}) })
revalidatePath("/management/grade/classes") revalidatePath("/management/grade/classes")
return { success: true, message: "Class created successfully", data: id } return { success: true, message: "Class created successfully", data: id }
@@ -223,73 +233,62 @@ export async function updateGradeClassAction(
try { try {
const ctx = await requirePermission(Permissions.CLASS_UPDATE) const ctx = await requirePermission(Permissions.CLASS_UPDATE)
const schoolName = formData.get("schoolName") const parsed = UpdateGradeClassSchema.safeParse({
const schoolId = formData.get("schoolId") classId,
const name = formData.get("name") schoolName: formData.get("schoolName"),
const grade = formData.get("grade") schoolId: formData.get("schoolId"),
const gradeId = formData.get("gradeId") name: formData.get("name"),
const teacherId = formData.get("teacherId") grade: formData.get("grade"),
const homeroom = formData.get("homeroom") gradeId: formData.get("gradeId"),
const room = formData.get("room") teacherId: formData.get("teacherId"),
const subjectTeachers = formData.get("subjectTeachers") homeroom: formData.get("homeroom"),
room: formData.get("room"),
if (typeof classId !== "string" || classId.trim().length === 0) { })
if (!parsed.success) {
return { success: false, message: "Missing class id" } return { success: false, message: "Missing class id" }
} }
// Verify access: Check if the class belongs to a managed grade const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, teacherId, homeroom, room } = parsed.data
const [cls] = await db const subjectTeachers = formData.get("subjectTeachers")
.select({ gradeId: classes.gradeId })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
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" } return { success: false, message: "Class not found or not linked to a grade" }
} }
const [managedGrade] = await db const isManager = await isGradeManager(classGradeId, ctx.userId)
.select({ id: grades.id }) if (!isManager) {
.from(grades)
.where(and(eq(grades.id, cls.gradeId), or(eq(grades.gradeHeadId, ctx.userId), eq(grades.teachingHeadId, ctx.userId))))
.limit(1)
if (!managedGrade) {
return { success: false, message: "You do not have permission to update this class" } return { success: false, message: "You do not have permission to update this class" }
} }
// If changing gradeId, verify target grade too // If changing gradeId, verify target grade too
if (typeof gradeId === "string" && gradeId !== cls.gradeId) { if (typeof gradeId === "string" && gradeId !== classGradeId) {
const [targetGrade] = await db const isTargetManager = await isGradeManager(gradeId, ctx.userId)
.select({ id: grades.id }) if (!isTargetManager) {
.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" } return { success: false, message: "You do not have permission to move class to this grade" }
} }
} }
try { try {
await updateAdminClass(classId, { await updateAdminClass(validatedClassId, {
schoolName: typeof schoolName === "string" ? schoolName : undefined, schoolName: schoolName ?? undefined,
schoolId: typeof schoolId === "string" ? schoolId : undefined, schoolId: schoolId ?? undefined,
name: typeof name === "string" ? name : undefined, name: name ?? undefined,
grade: typeof grade === "string" ? grade : undefined, grade: grade ?? undefined,
gradeId: typeof gradeId === "string" ? gradeId : undefined, gradeId: gradeId ?? undefined,
teacherId: typeof teacherId === "string" ? teacherId : undefined, teacherId: teacherId ?? undefined,
homeroom: typeof homeroom === "string" ? homeroom : undefined, homeroom: homeroom ?? undefined,
room: typeof room === "string" ? room : undefined, room: room ?? undefined,
}) })
if (typeof subjectTeachers === "string" && subjectTeachers.trim().length > 0) { if (typeof subjectTeachers === "string" && subjectTeachers.trim().length > 0) {
const parsed = JSON.parse(subjectTeachers) as unknown const parsedTeachers = JSON.parse(subjectTeachers) as unknown
if (!Array.isArray(parsed)) throw new Error("Invalid subject teachers") if (!Array.isArray(parsedTeachers)) throw new Error("Invalid subject teachers")
await setClassSubjectTeachers({ await setClassSubjectTeachers({
classId, classId: validatedClassId,
assignments: parsed.flatMap((item) => { assignments: parsedTeachers.flatMap((item) => {
if (!item || typeof item !== "object") return [] if (!item || typeof item !== "object") return []
const subject = (item as { subject?: unknown }).subject const subject = (item as { subject?: unknown }).subject
const teacherId = (item as { teacherId?: unknown }).teacherId const teacherId = (item as { teacherId?: unknown }).teacherId
@@ -322,33 +321,26 @@ export async function deleteGradeClassAction(classId: string): Promise<ActionSta
try { try {
const ctx = await requirePermission(Permissions.CLASS_DELETE) 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" } return { success: false, message: "Missing class id" }
} }
// Verify access const { classId: validatedClassId } = parsed.data
const [cls] = await db
.select({ gradeId: classes.gradeId })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
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" } return { success: false, message: "Class not found or not linked to a grade" }
} }
const [managedGrade] = await db const isManager = await isGradeManager(classGradeId, ctx.userId)
.select({ id: grades.id }) if (!isManager) {
.from(grades)
.where(and(eq(grades.id, cls.gradeId), or(eq(grades.gradeHeadId, ctx.userId), eq(grades.teachingHeadId, ctx.userId))))
.limit(1)
if (!managedGrade) {
return { success: false, message: "You do not have permission to delete this class" } return { success: false, message: "You do not have permission to delete this class" }
} }
try { try {
await deleteAdminClass(classId) await deleteAdminClass(validatedClassId)
revalidatePath("/management/grade/classes") revalidatePath("/management/grade/classes")
return { success: true, message: "Class deleted successfully" } return { success: true, message: "Class deleted successfully" }
} catch (error) { } catch (error) {
@@ -368,16 +360,16 @@ export async function enrollStudentByEmailAction(
try { try {
await requirePermission(Permissions.CLASS_ENROLL) await requirePermission(Permissions.CLASS_ENROLL)
const email = formData.get("email") const parsed = EnrollStudentByEmailSchema.safeParse({
if (typeof classId !== "string" || classId.trim().length === 0) { classId,
return { success: false, message: "Please select a class" } email: formData.get("email"),
} })
if (typeof email !== "string" || email.trim().length === 0) { if (!parsed.success) {
return { success: false, message: "Student email is required" } return { success: false, message: "Please select a class and provide student email" }
} }
try { try {
await enrollStudentByEmail(classId, email) await enrollStudentByEmail(parsed.data.classId, parsed.data.email)
revalidatePath("/teacher/classes/students") revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/my") revalidatePath("/teacher/classes/my")
return { success: true, message: "Student added successfully" } return { success: true, message: "Student added successfully" }
@@ -508,38 +500,29 @@ export async function createClassScheduleItemAction(
try { try {
await requirePermission(Permissions.CLASS_SCHEDULE) await requirePermission(Permissions.CLASS_SCHEDULE)
const classId = formData.get("classId") const parsed = CreateClassScheduleItemSchema.safeParse({
const weekday = formData.get("weekday") classId: formData.get("classId"),
const startTime = formData.get("startTime") weekday: formData.get("weekday"),
const endTime = formData.get("endTime") course: formData.get("course"),
const course = formData.get("course") startTime: formData.get("startTime"),
const location = formData.get("location") 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) { const { classId, weekday, course, startTime, endTime, location } = parsed.data
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" }
}
try { try {
const id = await createClassScheduleItem({ const id = await createClassScheduleItem({
classId, 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, startTime,
endTime, endTime,
course, course,
location: typeof location === "string" ? location : null, location: location ?? null,
}) })
revalidatePath("/teacher/classes/schedule") revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Schedule item created successfully", data: id } return { success: true, message: "Schedule item created successfully", data: id }
@@ -560,30 +543,30 @@ export async function updateClassScheduleItemAction(
try { try {
await requirePermission(Permissions.CLASS_SCHEDULE) await requirePermission(Permissions.CLASS_SCHEDULE)
const classId = formData.get("classId") const parsed = UpdateClassScheduleItemSchema.safeParse({
const weekday = formData.get("weekday") scheduleId,
const startTime = formData.get("startTime") classId: formData.get("classId"),
const endTime = formData.get("endTime") weekday: formData.get("weekday") || undefined,
const course = formData.get("course") course: formData.get("course"),
const location = formData.get("location") startTime: formData.get("startTime"),
endTime: formData.get("endTime"),
if (typeof scheduleId !== "string" || scheduleId.trim().length === 0) { location: formData.get("location"),
return { success: false, message: "Missing schedule id" } })
if (!parsed.success) {
return { success: false, message: "Missing or invalid schedule id" }
} }
const weekdayNum = typeof weekday === "string" && weekday.trim().length > 0 ? Number(weekday) : undefined const { scheduleId: validatedScheduleId, classId, weekday, course, startTime, endTime, location } = parsed.data
if (weekdayNum !== undefined && (!Number.isInteger(weekdayNum) || weekdayNum < 1 || weekdayNum > 7)) {
return { success: false, message: "Invalid weekday" }
}
try { try {
await updateClassScheduleItem(scheduleId, { await updateClassScheduleItem(validatedScheduleId, {
classId: typeof classId === "string" ? classId : undefined, classId: classId ?? undefined,
weekday: weekdayNum as 1 | 2 | 3 | 4 | 5 | 6 | 7 | undefined, // weekday 已被 Zod 校验为 1-7 的整数或 null/undefined断言为 Weekday 联合类型
startTime: typeof startTime === "string" ? startTime : undefined, weekday: (weekday ?? undefined) as 1 | 2 | 3 | 4 | 5 | 6 | 7 | undefined,
endTime: typeof endTime === "string" ? endTime : undefined, startTime: startTime ?? undefined,
course: typeof course === "string" ? course : undefined, endTime: endTime ?? undefined,
location: typeof location === "string" ? location : undefined, course: course ?? undefined,
location: location ?? undefined,
}) })
revalidatePath("/teacher/classes/schedule") revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Schedule item updated successfully" } return { success: true, message: "Schedule item updated successfully" }
@@ -600,12 +583,13 @@ export async function deleteClassScheduleItemAction(scheduleId: string): Promise
try { try {
await requirePermission(Permissions.CLASS_SCHEDULE) 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" } return { success: false, message: "Missing schedule id" }
} }
try { try {
await deleteClassScheduleItem(scheduleId) await deleteClassScheduleItem(parsed.data.scheduleId)
revalidatePath("/teacher/classes/schedule") revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Schedule item deleted successfully" } return { success: true, message: "Schedule item deleted successfully" }
} catch (error) { } catch (error) {
@@ -624,35 +608,32 @@ export async function createAdminClassAction(
try { try {
await requirePermission(Permissions.CLASS_CREATE) await requirePermission(Permissions.CLASS_CREATE)
const schoolName = formData.get("schoolName") const parsed = CreateAdminClassSchema.safeParse({
const schoolId = formData.get("schoolId") name: formData.get("name"),
const name = formData.get("name") grade: formData.get("grade"),
const grade = formData.get("grade") teacherId: formData.get("teacherId"),
const gradeId = formData.get("gradeId") schoolName: formData.get("schoolName"),
const teacherId = formData.get("teacherId") schoolId: formData.get("schoolId"),
const homeroom = formData.get("homeroom") gradeId: formData.get("gradeId"),
const room = formData.get("room") 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) { const { name, grade, teacherId, schoolName, schoolId, gradeId, homeroom, room } = parsed.data
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" }
}
try { try {
const id = await createAdminClass({ const id = await createAdminClass({
schoolName: typeof schoolName === "string" ? schoolName : null, schoolName: schoolName ?? null,
schoolId: typeof schoolId === "string" ? schoolId : null, schoolId: schoolId ?? null,
name, name,
grade, grade,
gradeId: typeof gradeId === "string" ? gradeId : null, gradeId: gradeId ?? null,
teacherId, teacherId,
homeroom: typeof homeroom === "string" ? homeroom : null, homeroom: homeroom ?? null,
room: typeof room === "string" ? room : null, room: room ?? null,
}) })
revalidatePath("/admin/school/classes") revalidatePath("/admin/school/classes")
revalidatePath("/teacher/classes/my") revalidatePath("/teacher/classes/my")
@@ -676,39 +657,43 @@ export async function updateAdminClassAction(
try { try {
await requirePermission(Permissions.CLASS_UPDATE) await requirePermission(Permissions.CLASS_UPDATE)
const schoolName = formData.get("schoolName") const parsed = UpdateAdminClassSchema.safeParse({
const schoolId = formData.get("schoolId") classId,
const name = formData.get("name") schoolName: formData.get("schoolName"),
const grade = formData.get("grade") schoolId: formData.get("schoolId"),
const gradeId = formData.get("gradeId") name: formData.get("name"),
const teacherId = formData.get("teacherId") grade: formData.get("grade"),
const homeroom = formData.get("homeroom") gradeId: formData.get("gradeId"),
const room = formData.get("room") teacherId: formData.get("teacherId"),
const subjectTeachers = formData.get("subjectTeachers") homeroom: formData.get("homeroom"),
room: formData.get("room"),
if (typeof classId !== "string" || classId.trim().length === 0) { })
if (!parsed.success) {
return { success: false, message: "Missing class id" } 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 { try {
await updateAdminClass(classId, { await updateAdminClass(validatedClassId, {
schoolName: typeof schoolName === "string" ? schoolName : undefined, schoolName: schoolName ?? undefined,
schoolId: typeof schoolId === "string" ? schoolId : undefined, schoolId: schoolId ?? undefined,
name: typeof name === "string" ? name : undefined, name: name ?? undefined,
grade: typeof grade === "string" ? grade : undefined, grade: grade ?? undefined,
gradeId: typeof gradeId === "string" ? gradeId : undefined, gradeId: gradeId ?? undefined,
teacherId: typeof teacherId === "string" ? teacherId : undefined, teacherId: teacherId ?? undefined,
homeroom: typeof homeroom === "string" ? homeroom : undefined, homeroom: homeroom ?? undefined,
room: typeof room === "string" ? room : undefined, room: room ?? undefined,
}) })
if (typeof subjectTeachers === "string" && subjectTeachers.trim().length > 0) { if (typeof subjectTeachers === "string" && subjectTeachers.trim().length > 0) {
const parsed = JSON.parse(subjectTeachers) as unknown const parsedTeachers = JSON.parse(subjectTeachers) as unknown
if (!Array.isArray(parsed)) throw new Error("Invalid subject teachers") if (!Array.isArray(parsedTeachers)) throw new Error("Invalid subject teachers")
await setClassSubjectTeachers({ await setClassSubjectTeachers({
classId, classId: validatedClassId,
assignments: parsed.flatMap((item) => { assignments: parsedTeachers.flatMap((item) => {
if (!item || typeof item !== "object") return [] if (!item || typeof item !== "object") return []
const subject = (item as { subject?: unknown }).subject const subject = (item as { subject?: unknown }).subject
const teacherId = (item as { teacherId?: unknown }).teacherId const teacherId = (item as { teacherId?: unknown }).teacherId
@@ -744,12 +729,13 @@ export async function deleteAdminClassAction(classId: string): Promise<ActionSta
try { try {
await requirePermission(Permissions.CLASS_DELETE) 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" } return { success: false, message: "Missing class id" }
} }
try { try {
await deleteAdminClass(classId) await deleteAdminClass(parsed.data.classId)
revalidatePath("/admin/school/classes") revalidatePath("/admin/school/classes")
revalidatePath("/teacher/classes/my") revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students") revalidatePath("/teacher/classes/students")

View File

@@ -31,6 +31,12 @@ import {
isDuplicateInvitationCodeError, isDuplicateInvitationCodeError,
} from "./data-access" } 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[]> => { export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> => {
const [rows, subjectRows] = await Promise.all([ const [rows, subjectRows] = await Promise.all([
(async () => { (async () => {
@@ -79,7 +85,8 @@ export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> =>
asc(classes.homeroom), asc(classes.homeroom),
asc(classes.room) asc(classes.room)
) )
} catch { } catch (error) {
console.error("getAdminClasses primary query failed, falling back:", error)
return await db return await db
.select({ .select({
id: classes.id, id: classes.id,
@@ -132,8 +139,8 @@ export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> =>
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>() const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
for (const r of subjectRows) { for (const r of subjectRows) {
const subject = r.subject as ClassSubject const subject = toClassSubject(r.subject)
if (!DEFAULT_CLASS_SUBJECTS.includes(subject)) continue if (!subject) continue
const teacher = const teacher =
typeof r.teacherId === "string" && r.teacherId.length > 0 typeof r.teacherId === "string" && r.teacherId.length > 0
? { id: r.teacherId, name: r.teacherName ?? "Unnamed", email: r.teacherEmail ?? "" } ? { 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.homeroom),
asc(classes.room) asc(classes.room)
) )
} catch { } catch (error) {
console.error("getGradeManagedClasses primary query failed:", error)
return [] return []
} }
})(), })(),
@@ -256,8 +264,8 @@ export const getGradeManagedClasses = cache(async (userId: string): Promise<Admi
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>() const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
for (const r of subjectRows) { for (const r of subjectRows) {
const subject = r.subject as ClassSubject const subject = toClassSubject(r.subject)
if (!DEFAULT_CLASS_SUBJECTS.includes(subject)) continue if (!subject) continue
const teacher = const teacher =
typeof r.teacherId === "string" && r.teacherId.length > 0 typeof r.teacherId === "string" && r.teacherId.length > 0
? { id: r.teacherId, name: r.teacherName ?? "Unnamed", email: r.teacherEmail ?? "" } ? { 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 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 return await db
.select({ .select({
id: grades.id, id: grades.id,
@@ -346,7 +354,11 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
.select({ id: subjects.id, name: subjects.name }) .select({ id: subjects.id, name: subjects.name })
.from(subjects) .from(subjects)
.where(inArray(subjects.name, DEFAULT_CLASS_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 db.transaction(async (tx) => {
await tx.insert(classes).values({ await tx.insert(classes).values({
@@ -362,13 +374,11 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
teacherId, teacherId,
}) })
const values = DEFAULT_CLASS_SUBJECTS const values = DEFAULT_CLASS_SUBJECTS.flatMap((name) => {
.filter((name) => idByName.has(name)) const subjectId = idByName.get(name)
.map((name) => ({ if (!subjectId) return []
classId: id, return [{ classId: id, subjectId, teacherId: null }]
subjectId: idByName.get(name)!, })
teacherId: null,
}))
await tx.insert(classSubjectTeachers).values(values) await tx.insert(classSubjectTeachers).values(values)
}) })
return id return id
@@ -378,8 +388,6 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
} }
} }
throw new Error("Failed to create class") throw new Error("Failed to create class")
return id
} }
export async function updateAdminClass( export async function updateAdminClass(

View File

@@ -9,23 +9,21 @@ import {
classEnrollments, classEnrollments,
classSchedule, classSchedule,
} from "@/shared/db/schema" } from "@/shared/db/schema"
import {
insertClassScheduleItem,
updateClassScheduleItemById,
deleteClassScheduleItemById,
} from "@/modules/scheduling/data-access"
import type { import type {
ClassScheduleItem, ClassScheduleItem,
CreateClassScheduleItemInput,
StudentScheduleItem, StudentScheduleItem,
UpdateClassScheduleItemInput,
} from "./types" } from "./types"
import { import {
getAccessibleClassIdsForTeacher, getAccessibleClassIdsForTeacher,
getSessionTeacherId, getSessionTeacherId,
getTeacherIdForMutations,
} from "./data-access" } 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[]> => { export const getStudentSchedule = cache(async (studentId: string): Promise<StudentScheduleItem[]> => {
const id = studentId.trim() const id = studentId.trim()
if (!id) return [] if (!id) return []
@@ -51,7 +49,7 @@ export const getStudentSchedule = cache(async (studentId: string): Promise<Stude
id: r.id, id: r.id,
classId: r.classId, classId: r.classId,
className: r.className, className: r.className,
weekday: r.weekday as StudentScheduleItem["weekday"], weekday: toWeekday(r.weekday),
startTime: r.startTime, startTime: r.startTime,
endTime: r.endTime, endTime: r.endTime,
course: r.course, course: r.course,
@@ -90,7 +88,7 @@ export const getClassSchedule = cache(
return rows.map((r) => ({ return rows.map((r) => ({
id: r.id, id: r.id,
classId: r.classId, classId: r.classId,
weekday: r.weekday as ClassScheduleItem["weekday"], weekday: toWeekday(r.weekday),
startTime: r.startTime, startTime: r.startTime,
endTime: r.endTime, endTime: r.endTime,
course: r.course, 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)
}

View File

@@ -1,21 +1,23 @@
import "server-only"; import "server-only";
import { cache } from "react" 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 { db } from "@/shared/db"
import { import {
classes, classes,
classEnrollments, classEnrollments,
grades, grades,
homeworkAssignmentQuestions,
homeworkAssignmentTargets,
homeworkAssignments,
homeworkSubmissions,
schools, schools,
subjects,
exams,
} from "@/shared/db/schema" } from "@/shared/db/schema"
import {
getAssignmentIdsForStudents,
getAssignmentMaxScoreById,
getAssignmentTargetCounts,
getHomeworkAssignmentsByIds,
getHomeworkAssignmentsWithSubject,
getHomeworkSubmissionsForStudents,
} from "@/modules/homework/data-access-classes"
import type { import type {
ClassHomeworkInsights, ClassHomeworkInsights,
ClassHomeworkAssignmentStats, ClassHomeworkAssignmentStats,
@@ -23,6 +25,7 @@ import type {
GradeHomeworkInsights, GradeHomeworkInsights,
ScoreStats, ScoreStats,
} from "./types" } from "./types"
import type { HomeworkSubmissionRecord } from "@/modules/homework/data-access-classes"
import { import {
getAccessibleClassIdsForTeacher, getAccessibleClassIdsForTeacher,
getSessionTeacherId, 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( export const getClassHomeworkInsights = cache(
async (params: { classId: string; teacherId?: string; limit?: number }): Promise<ClassHomeworkInsights | null> => { async (params: { classId: string; teacherId?: string; limit?: number }): Promise<ClassHomeworkInsights | null> => {
const teacherId = params.teacherId ?? (await getSessionTeacherId()) const teacherId = params.teacherId ?? (await getSessionTeacherId())
@@ -127,12 +198,7 @@ export const getClassHomeworkInsights = cache(
} }
} }
const assignmentIdRows = await db const assignmentIds = await getAssignmentIdsForStudents(studentIds)
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
const assignmentIds = assignmentIdRows.map((r) => r.assignmentId)
if (assignmentIds.length === 0) { if (assignmentIds.length === 0) {
return { return {
class: { class: {
@@ -151,26 +217,11 @@ export const getClassHomeworkInsights = cache(
} }
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50 const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
const assignmentConditions: SQL[] = [inArray(homeworkAssignments.id, assignmentIds)] const assignments = await getHomeworkAssignmentsWithSubject({
if (subjectIdFilter.length > 0) { assignmentIds,
assignmentConditions.push(inArray(exams.subjectId, subjectIdFilter)) subjectIdFilter: subjectIdFilter.length > 0 ? subjectIdFilter : undefined,
} limit,
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 usedAssignmentIds = assignments.map((a) => a.id) const usedAssignmentIds = assignments.map((a) => a.id)
if (usedAssignmentIds.length === 0) { if (usedAssignmentIds.length === 0) {
@@ -190,86 +241,19 @@ export const getClassHomeworkInsights = cache(
} }
} }
const maxScoreRows = await db const [maxScoreByAssignmentId, targetCountByAssignmentId, submissions] = await Promise.all([
.select({ getAssignmentMaxScoreById(usedAssignmentIds),
assignmentId: homeworkAssignmentQuestions.assignmentId, getAssignmentTargetCounts({ assignmentIds: usedAssignmentIds, studentIds }),
maxScore: sql<number>`COALESCE(SUM(${homeworkAssignmentQuestions.score}), 0)`, getHomeworkSubmissionsForStudents({ assignmentIds: usedAssignmentIds, studentIds }),
}) ])
.from(homeworkAssignmentQuestions)
.where(inArray(homeworkAssignmentQuestions.assignmentId, usedAssignmentIds))
.groupBy(homeworkAssignmentQuestions.assignmentId)
const maxScoreByAssignmentId = new Map<string, number>() const latestByKey = buildLatestSubmissionByKey(submissions)
for (const r of maxScoreRows) maxScoreByAssignmentId.set(r.assignmentId, Number(r.maxScore ?? 0)) const { stats, allScored } = computeAssignmentStats({
assignments,
const targetCountRows = await db studentIds,
.select({ latestByKey,
assignmentId: homeworkAssignmentTargets.assignmentId, maxScoreByAssignmentId,
targetCount: sql<number>`COUNT(*)`, targetCountByAssignmentId,
})
.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 overallScores = toScoreStats(allScored) const overallScores = toScoreStats(allScored)
@@ -390,12 +374,7 @@ export const getGradeHomeworkInsights = cache(
} }
} }
const assignmentIdRows = await db const assignmentIds = await getAssignmentIdsForStudents(studentIds)
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
const assignmentIds = assignmentIdRows.map((r) => r.assignmentId)
if (assignmentIds.length === 0) { if (assignmentIds.length === 0) {
const summaries: GradeHomeworkClassSummary[] = classRows.map((c) => { const summaries: GradeHomeworkClassSummary[] = classRows.map((c) => {
const bucket = studentsByClassId.get(c.id) ?? { all: new Set<string>(), active: new Set<string>() } 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 limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
const assignments = await db.query.homeworkAssignments.findMany({ const assignments = await getHomeworkAssignmentsByIds({ assignmentIds, limit })
where: inArray(homeworkAssignments.id, assignmentIds),
orderBy: [desc(homeworkAssignments.createdAt)],
limit,
})
const usedAssignmentIds = assignments.map((a) => a.id) const usedAssignmentIds = assignments.map((a) => a.id)
if (usedAssignmentIds.length === 0) { if (usedAssignmentIds.length === 0) {
@@ -452,85 +427,19 @@ export const getGradeHomeworkInsights = cache(
} }
} }
const maxScoreRows = await db const [maxScoreByAssignmentId, targetCountByAssignmentId, submissions] = await Promise.all([
.select({ getAssignmentMaxScoreById(usedAssignmentIds),
assignmentId: homeworkAssignmentQuestions.assignmentId, getAssignmentTargetCounts({ assignmentIds: usedAssignmentIds, studentIds }),
maxScore: sql<number>`COALESCE(SUM(${homeworkAssignmentQuestions.score}), 0)`, getHomeworkSubmissionsForStudents({ assignmentIds: usedAssignmentIds, studentIds }),
}) ])
.from(homeworkAssignmentQuestions)
.where(inArray(homeworkAssignmentQuestions.assignmentId, usedAssignmentIds))
.groupBy(homeworkAssignmentQuestions.assignmentId)
const maxScoreByAssignmentId = new Map<string, number>() const latestByKey = buildLatestSubmissionByKey(submissions)
for (const r of maxScoreRows) maxScoreByAssignmentId.set(r.assignmentId, Number(r.maxScore ?? 0)) const { stats, allScored } = computeAssignmentStats({
assignments,
const targetCountRows = await db studentIds,
.select({ latestByKey,
assignmentId: homeworkAssignmentTargets.assignmentId, maxScoreByAssignmentId,
targetCount: sql<number>`COUNT(*)`, targetCountByAssignmentId,
})
.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 overallScores = toScoreStats(allScored) const overallScores = toScoreStats(allScored)

View File

@@ -1,19 +1,20 @@
import "server-only"; import "server-only";
import { cache } from "react" 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 { db } from "@/shared/db"
import { import {
classes, classes,
classEnrollments, classEnrollments,
homeworkAssignmentTargets,
homeworkAssignments,
homeworkSubmissions,
subjects, subjects,
exams,
users, users,
} from "@/shared/db/schema" } from "@/shared/db/schema"
import {
getAssignmentIdsForStudents,
getHomeworkSubmissionsForAssignments,
getPublishedHomeworkAssignmentsWithSubject,
} from "@/modules/homework/data-access-classes"
import type { import type {
ClassStudent, ClassStudent,
StudentEnrolledClass, StudentEnrolledClass,
@@ -29,31 +30,12 @@ export const getStudentsSubjectScores = cache(
async (studentIds: string[]): Promise<Map<string, Record<string, number | null>>> => { async (studentIds: string[]): Promise<Map<string, Record<string, number | null>>> => {
if (studentIds.length === 0) return new Map() if (studentIds.length === 0) return new Map()
// 1. Find assignments targeted at these students // 1. Find assignments targeted at these students (via homework module data-access)
const assignmentTargets = await db const assignmentIds = await getAssignmentIdsForStudents(studentIds)
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
const assignmentIds = Array.from(new Set(assignmentTargets.map(t => t.assignmentId)))
if (assignmentIds.length === 0) return new Map() if (assignmentIds.length === 0) return new Map()
// 2. Get assignment details including subject from linked exam // 2. Get published assignment details including subject from linked exam (via homework module)
const assignments = await db const assignments = await getPublishedHomeworkAssignmentsWithSubject({ assignmentIds })
.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))
// 3. Filter subjects (exclude PE, Music, Art) // 3. Filter subjects (exclude PE, Music, Art)
const excludeSubjects = ["体育", "音乐", "美术"] const excludeSubjects = ["体育", "音乐", "美术"]
@@ -70,17 +52,8 @@ export const getStudentsSubjectScores = cache(
const targetAssignmentIds = Array.from(subjectAssignments.values()) const targetAssignmentIds = Array.from(subjectAssignments.values())
if (targetAssignmentIds.length === 0) return new Map() if (targetAssignmentIds.length === 0) return new Map()
// 4. Get submissions for these assignments // 4. Get submissions for these assignments (via homework module)
const submissions = await db const submissions = await getHomeworkSubmissionsForAssignments(targetAssignmentIds)
.select({
studentId: homeworkSubmissions.studentId,
assignmentId: homeworkSubmissions.assignmentId,
score: homeworkSubmissions.score,
createdAt: homeworkSubmissions.createdAt,
})
.from(homeworkSubmissions)
.where(inArray(homeworkSubmissions.assignmentId, targetAssignmentIds))
.orderBy(desc(homeworkSubmissions.createdAt))
// 5. Map back to subject scores per student // 5. Map back to subject scores per student
const studentScores = new Map<string, Record<string, number | null>>() const studentScores = new Map<string, Record<string, number | null>>()
@@ -95,11 +68,11 @@ export const getStudentsSubjectScores = cache(
const subject = assignmentSubjectMap.get(s.assignmentId) const subject = assignmentSubjectMap.get(s.assignmentId)
if (!subject) continue if (!subject) continue
if (!studentScores.has(s.studentId)) { const existing = studentScores.get(s.studentId)
studentScores.set(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) // Only set if not already set (since we ordered by desc createdAt, first one is latest)
if (scores[subject] === undefined) { if (scores[subject] === undefined) {
scores[subject] = s.score scores[subject] = s.score
@@ -183,7 +156,8 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
.leftJoin(users, eq(users.id, classes.teacherId)) .leftJoin(users, eq(users.id, classes.teacherId))
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active"))) .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)) .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 return await db
.select({ .select({
id: classes.id, id: classes.id,

View File

@@ -26,6 +26,12 @@ import type {
import { getClassHomeworkInsights } from "./data-access-stats" import { getClassHomeworkInsights } from "./data-access-stats"
import { getClassSchedule } from "./data-access-schedule" 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> => { export const getSessionTeacherId = async (): Promise<string | null> => {
const { auth } = await import("@/auth") const { auth } = await import("@/auth")
const session = await auth() const session = await auth()
@@ -118,14 +124,44 @@ export const compareClassLike = (
} }
export const getAccessibleClassIdsForTeacher = async (teacherId: string): Promise<string[]> => { export const getAccessibleClassIdsForTeacher = async (teacherId: string): Promise<string[]> => {
const ownedIds = await db.select({ id: classes.id }).from(classes).where(eq(classes.teacherId, teacherId)) const [ownedIds, assignedIds] = await Promise.all([
const assignedIds = await db db.select({ id: classes.id }).from(classes).where(eq(classes.teacherId, teacherId)),
db
.select({ id: classSubjectTeachers.classId }) .select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers) .from(classSubjectTeachers)
.where(eq(classSubjectTeachers.teacherId, teacherId)) .where(eq(classSubjectTeachers.teacherId, teacherId)),
])
return Array.from(new Set([...ownedIds.map((x) => x.id), ...assignedIds.map((x) => x.id)])) 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[]> => { export const getTeacherSubjectIdsForClass = async (teacherId: string, classId: string): Promise<string[]> => {
const rows = await db const rows = await db
.select({ subjectId: classSubjectTeachers.subjectId }) .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)))) 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)))
}
/**
* 获取班级所有活跃学生 IDstatus = '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[]> => { export const getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise<TeacherClass[]> => {
const teacherId = params?.teacherId ?? (await getSessionTeacherId()) const teacherId = params?.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return [] if (!teacherId) return []
@@ -237,8 +445,8 @@ export const getTeacherTeachingSubjects = cache(async (): Promise<ClassSubject[]
.orderBy(asc(subjects.name)) .orderBy(asc(subjects.name))
return rows return rows
.map((r) => r.subject as ClassSubject) .map((r) => toClassSubject(r.subject))
.filter((s) => DEFAULT_CLASS_SUBJECTS.includes(s)) .filter((s): s is ClassSubject => s !== null)
}) })
export async function createTeacherClass(data: CreateTeacherClassInput): Promise<string> { 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 }) .select({ id: subjects.id, name: subjects.name })
.from(subjects) .from(subjects)
.where(inArray(subjects.name, DEFAULT_CLASS_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 db.transaction(async (tx) => {
await tx.insert(classes).values({ await tx.insert(classes).values({
@@ -279,13 +491,11 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
teacherId, teacherId,
}) })
const values = DEFAULT_CLASS_SUBJECTS const values = DEFAULT_CLASS_SUBJECTS.flatMap((name) => {
.filter((name) => idByName.has(name)) const subjectId = idByName.get(name)
.map((name) => ({ if (!subjectId) return []
classId: id, return [{ classId: id, subjectId, teacherId: null }]
subjectId: idByName.get(name)!, })
teacherId: null,
}))
await tx.insert(classSubjectTeachers).values(values) await tx.insert(classSubjectTeachers).values(values)
}) })
return id return id
@@ -295,8 +505,6 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
} }
} }
throw new Error("Failed to create class") throw new Error("Failed to create class")
return id
} }
export async function ensureClassInvitationCode(classId: string): Promise<string> { export async function ensureClassInvitationCode(classId: string): Promise<string> {
@@ -558,15 +766,17 @@ export async function setClassSubjectTeachers(params: {
.select({ id: subjects.id, name: subjects.name }) .select({ id: subjects.id, name: subjects.name })
.from(subjects) .from(subjects)
.where(inArray(subjects.name, DEFAULT_CLASS_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 const values = DEFAULT_CLASS_SUBJECTS.flatMap((name) => {
.filter((name) => idByName.has(name)) const subjectId = idByName.get(name)
.map((name) => ({ if (!subjectId) return []
classId, return [{ classId, subjectId, teacherId: teacherBySubject.get(name) ?? null }]
subjectId: idByName.get(name)!, })
teacherId: teacherBySubject.get(name) ?? null,
}))
await db await db
.insert(classSubjectTeachers) .insert(classSubjectTeachers)

View 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>

View File

@@ -100,24 +100,6 @@ export type ClassScheduleItem = {
location?: string | null 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 = { export type StudentEnrolledClass = {
id: string id: string
schoolName?: string | null schoolName?: string | null

View File

@@ -239,6 +239,7 @@ export async function deleteCoursePlanItemAction(
try { try {
await requirePermission(Permissions.COURSE_PLAN_MANAGE) await requirePermission(Permissions.COURSE_PLAN_MANAGE)
await deleteCoursePlanItem(id) await deleteCoursePlanItem(id)
revalidatePlanPaths()
return { success: true, message: "Week plan deleted" } return { success: true, message: "Week plan deleted" }
} catch (e) { } catch (e) {
return handleError(e) return handleError(e)
@@ -255,6 +256,7 @@ export async function toggleCoursePlanItemCompletedAction(
isCompleted: completed, isCompleted: completed,
completedAt: completed ? new Date().toISOString().slice(0, 10) : null, completedAt: completed ? new Date().toISOString().slice(0, 10) : null,
}) })
revalidatePlanPaths()
return { return {
success: true, success: true,
message: completed ? "Marked as completed" : "Marked as incomplete", message: completed ? "Marked as completed" : "Marked as incomplete",

View File

@@ -146,7 +146,7 @@ export const getCoursePlans = cache(
if (params?.teacherId) conditions.push(eq(coursePlans.teacherId, params.teacherId)) if (params?.teacherId) conditions.push(eq(coursePlans.teacherId, params.teacherId))
if (params?.subjectId) conditions.push(eq(coursePlans.subjectId, params.subjectId)) if (params?.subjectId) conditions.push(eq(coursePlans.subjectId, params.subjectId))
if (params?.status) if (params?.status)
conditions.push(eq(coursePlans.status, params.status as CoursePlanStatus)) conditions.push(eq(coursePlans.status, params.status))
const query = buildPlanSelect() const query = buildPlanSelect()
const rows = await (conditions.length > 0 const rows = await (conditions.length > 0
@@ -155,7 +155,8 @@ export const getCoursePlans = cache(
).orderBy(desc(coursePlans.createdAt)) ).orderBy(desc(coursePlans.createdAt))
return rows.map(mapPlanRow) return rows.map(mapPlanRow)
} catch { } catch (error) {
console.error("getCoursePlans failed:", error)
return [] return []
} }
} }
@@ -180,7 +181,8 @@ export const getCoursePlanById = cache(
...mapPlanRow(planRow), ...mapPlanRow(planRow),
items: itemRows.map(mapItemRow), items: itemRows.map(mapItemRow),
} }
} catch { } catch (error) {
console.error("getCoursePlanById failed:", error)
return null return null
} }
} }
@@ -314,7 +316,8 @@ export const getSubjectOptions = cache(async (): Promise<{ id: string; name: str
.from(subjects) .from(subjects)
.orderBy(asc(subjects.order), asc(subjects.name)) .orderBy(asc(subjects.order), asc(subjects.name))
return rows.map((r) => ({ id: r.id, name: r.name })) return rows.map((r) => ({ id: r.id, name: r.name }))
} catch { } catch (error) {
console.error("getSubjectOptions failed:", error)
return [] return []
} }
}) })

View File

@@ -13,6 +13,14 @@ import {
publishDiagnosticReport, publishDiagnosticReport,
deleteDiagnosticReport, deleteDiagnosticReport,
} from "./data-access-reports" } from "./data-access-reports"
import {
GenerateStudentReportSchema,
GenerateClassReportSchema,
PublishReportSchema,
DeleteReportSchema,
GetDiagnosticReportsSchema,
GetDiagnosticReportByIdSchema,
} from "./schema"
import type { DiagnosticReportQueryParams } from "./types" import type { DiagnosticReportQueryParams } from "./types"
/** 生成学生个人诊断报告 */ /** 生成学生个人诊断报告 */
@@ -23,15 +31,15 @@ export async function generateStudentReportAction(
try { try {
const ctx = await requirePermission(Permissions.DIAGNOSTIC_MANAGE) const ctx = await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
const studentId = formData.get("studentId") const parsed = GenerateStudentReportSchema.safeParse({
const period = formData.get("period") studentId: formData.get("studentId"),
if (typeof studentId !== "string" || studentId.length === 0) { period: formData.get("period"),
return { success: false, message: "Missing studentId" } })
} if (!parsed.success) {
if (typeof period !== "string" || period.length === 0) { return { success: false, message: "Missing studentId or period" }
return { success: false, message: "Missing period" }
} }
const { studentId, period } = parsed.data
const id = await generateDiagnosticReport(studentId, period, ctx.userId) const id = await generateDiagnosticReport(studentId, period, ctx.userId)
revalidatePath("/teacher/diagnostic") revalidatePath("/teacher/diagnostic")
revalidatePath(`/teacher/diagnostic/student/${studentId}`) revalidatePath(`/teacher/diagnostic/student/${studentId}`)
@@ -51,15 +59,15 @@ export async function generateClassReportAction(
try { try {
const ctx = await requirePermission(Permissions.DIAGNOSTIC_MANAGE) const ctx = await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
const classId = formData.get("classId") const parsed = GenerateClassReportSchema.safeParse({
const period = formData.get("period") classId: formData.get("classId"),
if (typeof classId !== "string" || classId.length === 0) { period: formData.get("period"),
return { success: false, message: "Missing classId" } })
} if (!parsed.success) {
if (typeof period !== "string" || period.length === 0) { return { success: false, message: "Missing classId or period" }
return { success: false, message: "Missing period" }
} }
const { classId, period } = parsed.data
const id = await generateClassDiagnosticReport(classId, period, ctx.userId) const id = await generateClassDiagnosticReport(classId, period, ctx.userId)
revalidatePath("/teacher/diagnostic") revalidatePath("/teacher/diagnostic")
revalidatePath(`/teacher/diagnostic/class/${classId}`) revalidatePath(`/teacher/diagnostic/class/${classId}`)
@@ -79,12 +87,14 @@ export async function publishReportAction(
try { try {
await requirePermission(Permissions.DIAGNOSTIC_MANAGE) await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
const id = formData.get("id") const parsed = PublishReportSchema.safeParse({
if (typeof id !== "string" || id.length === 0) { id: formData.get("id"),
})
if (!parsed.success) {
return { success: false, message: "Missing report id" } return { success: false, message: "Missing report id" }
} }
await publishDiagnosticReport(id) await publishDiagnosticReport(parsed.data.id)
revalidatePath("/teacher/diagnostic") revalidatePath("/teacher/diagnostic")
return { success: true, message: "Report published" } return { success: true, message: "Report published" }
} catch (e) { } catch (e) {
@@ -102,12 +112,14 @@ export async function deleteReportAction(
try { try {
await requirePermission(Permissions.DIAGNOSTIC_MANAGE) await requirePermission(Permissions.DIAGNOSTIC_MANAGE)
const id = formData.get("id") const parsed = DeleteReportSchema.safeParse({
if (typeof id !== "string" || id.length === 0) { id: formData.get("id"),
})
if (!parsed.success) {
return { success: false, message: "Missing report id" } return { success: false, message: "Missing report id" }
} }
await deleteDiagnosticReport(id) await deleteDiagnosticReport(parsed.data.id)
revalidatePath("/teacher/diagnostic") revalidatePath("/teacher/diagnostic")
return { success: true, message: "Report deleted" } return { success: true, message: "Report deleted" }
} catch (e) { } catch (e) {
@@ -123,7 +135,13 @@ export async function getDiagnosticReportsAction(
): Promise<ActionState<Awaited<ReturnType<typeof getDiagnosticReports>>>> { ): Promise<ActionState<Awaited<ReturnType<typeof getDiagnosticReports>>>> {
try { try {
await requirePermission(Permissions.DIAGNOSTIC_READ) 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 } return { success: true, data: reports }
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message } if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
@@ -138,7 +156,13 @@ export async function getDiagnosticReportByIdAction(
): Promise<ActionState<Awaited<ReturnType<typeof getDiagnosticReportById>>>> { ): Promise<ActionState<Awaited<ReturnType<typeof getDiagnosticReportById>>>> {
try { try {
await requirePermission(Permissions.DIAGNOSTIC_READ) 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 } return { success: true, data: report }
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message } if (e instanceof PermissionDeniedError) return { success: false, message: e.message }

View File

@@ -1,6 +1,8 @@
import "server-only" import "server-only"
import { createId } from "@paralleldrive/cuid2"
import { and, desc, eq, inArray } from "drizzle-orm" import { and, desc, eq, inArray } from "drizzle-orm"
import { cache } from "react"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { learningDiagnosticReports, users } from "@/shared/db/schema" 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 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 => ({ const serializeReport = (r: typeof learningDiagnosticReports.$inferSelect): DiagnosticReport => ({
id: r.id, id: r.id,
studentId: r.studentId, studentId: r.studentId,
@@ -26,9 +34,9 @@ const serializeReport = (r: typeof learningDiagnosticReports.$inferSelect): Diag
reportType: r.reportType, reportType: r.reportType,
period: r.period, period: r.period,
summary: r.summary, summary: r.summary,
strengths: (r.strengths as string[] | null) ?? null, strengths: toStringArrayNullable(r.strengths),
weaknesses: (r.weaknesses as string[] | null) ?? null, weaknesses: toStringArrayNullable(r.weaknesses),
recommendations: (r.recommendations as string[] | null) ?? null, recommendations: toStringArrayNullable(r.recommendations),
overallScore: r.overallScore !== null ? toNumber(r.overallScore) : null, overallScore: r.overallScore !== null ? toNumber(r.overallScore) : null,
status: r.status, status: r.status,
createdAt: r.createdAt.toISOString(), 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 summaryText = `学生 ${summary.studentName}${period} 期间整体掌握度 ${overallScore.toFixed(1)}%,共评估 ${summary.totalKnowledgePoints} 个知识点,强项 ${strengths.length} 个,弱项 ${weaknesses.length} 个。`
const { createId } = await import("@paralleldrive/cuid2")
const id = createId() const id = createId()
await db.insert(learningDiagnosticReports).values({ await db.insert(learningDiagnosticReports).values({
id, id,
@@ -100,7 +107,6 @@ export async function generateClassDiagnosticReport(
const summaryText = `班级 ${summary.className}${period} 期间整体掌握度 ${summary.averageMastery.toFixed(1)}%,学生 ${summary.studentCount} 人,需重点关注 ${summary.studentsNeedingAttention.length} 人。` const summaryText = `班级 ${summary.className}${period} 期间整体掌握度 ${summary.averageMastery.toFixed(1)}%,学生 ${summary.studentCount} 人,需重点关注 ${summary.studentsNeedingAttention.length} 人。`
const { createId } = await import("@paralleldrive/cuid2")
const id = createId() const id = createId()
await db.insert(learningDiagnosticReports).values({ await db.insert(learningDiagnosticReports).values({
id, id,
@@ -119,9 +125,8 @@ export async function generateClassDiagnosticReport(
} }
/** 查询诊断报告列表 */ /** 查询诊断报告列表 */
export async function getDiagnosticReports( export const getDiagnosticReports = cache(
filters: DiagnosticReportQueryParams async (filters: DiagnosticReportQueryParams): Promise<DiagnosticReportWithDetails[]> => {
): Promise<DiagnosticReportWithDetails[]> {
const conditions = [] const conditions = []
if (filters.studentId) conditions.push(eq(learningDiagnosticReports.studentId, filters.studentId)) if (filters.studentId) conditions.push(eq(learningDiagnosticReports.studentId, filters.studentId))
if (filters.reportType) conditions.push(eq(learningDiagnosticReports.reportType, filters.reportType)) if (filters.reportType) conditions.push(eq(learningDiagnosticReports.reportType, filters.reportType))
@@ -155,12 +160,12 @@ export async function getDiagnosticReports(
studentName: r.studentName ?? "Unknown", studentName: r.studentName ?? "Unknown",
generatedByName: r.report.generatedBy ? generatorMap.get(r.report.generatedBy) ?? "Unknown" : null, generatedByName: r.report.generatedBy ? generatorMap.get(r.report.generatedBy) ?? "Unknown" : null,
})) }))
} },
)
/** 获取报告详情 */ /** 获取报告详情 */
export async function getDiagnosticReportById( export const getDiagnosticReportById = cache(
id: string async (id: string): Promise<DiagnosticReportWithDetails | null> => {
): Promise<DiagnosticReportWithDetails | null> {
const [row] = await db const [row] = await db
.select({ report: learningDiagnosticReports, studentName: users.name }) .select({ report: learningDiagnosticReports, studentName: users.name })
.from(learningDiagnosticReports) .from(learningDiagnosticReports)
@@ -183,7 +188,8 @@ export async function getDiagnosticReportById(
studentName: row.studentName ?? "Unknown", studentName: row.studentName ?? "Unknown",
generatedByName, generatedByName,
} }
} },
)
/** 发布诊断报告 */ /** 发布诊断报告 */
export async function publishDiagnosticReport(id: string): Promise<void> { export async function publishDiagnosticReport(id: string): Promise<void> {

View File

@@ -1,18 +1,15 @@
import "server-only" 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 { db } from "@/shared/db"
import { import { knowledgePointMastery, knowledgePoints } from "@/shared/db/schema"
classEnrollments,
classes, import { getClassNameById, getActiveStudentIdsByClassId, getClassExists } from "@/modules/classes/data-access"
examSubmissions, import { getExamSubmissionWithAnswers } from "@/modules/exams/data-access"
knowledgePointMastery, import { getKnowledgePointsForQuestions } from "@/modules/questions/data-access"
knowledgePoints, import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access"
questionsToKnowledgePoints,
submissionAnswers,
users,
} from "@/shared/db/schema"
import type { import type {
ClassMasterySummary, 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 const rows = await db
.select({ .select({
mastery: knowledgePointMastery, mastery: knowledgePointMastery,
@@ -59,11 +56,12 @@ export async function getStudentMastery(studentId: string): Promise<MasteryWithK
knowledgePointName: r.kpName ?? "Unknown", knowledgePointName: r.kpName ?? "Unknown",
knowledgePointDescription: r.kpDescription, knowledgePointDescription: r.kpDescription,
})) }))
} })
/** 获取学生掌握度摘要(含强项/弱项分析) */ /** 获取学生掌握度摘要(含强项/弱项分析) */
export async function getStudentMasterySummary(studentId: string): Promise<StudentMasterySummary | null> { export const getStudentMasterySummary = cache(async (studentId: string): Promise<StudentMasterySummary | null> => {
const [student] = await db.select({ name: users.name }).from(users).where(eq(users.id, studentId)).limit(1) const userMap = await getUserNamesByIds([studentId])
const student = userMap.get(studentId)
if (!student) return null if (!student) return null
const allMastery = await getStudentMastery(studentId) const allMastery = await getStudentMastery(studentId)
@@ -72,54 +70,50 @@ export async function getStudentMasterySummary(studentId: string): Promise<Stude
? round2(allMastery.reduce((acc, m) => acc + m.masteryLevel, 0) / allMastery.length) ? round2(allMastery.reduce((acc, m) => acc + m.masteryLevel, 0) / allMastery.length)
: 0 : 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 { return {
studentId, studentId,
studentName: student.name ?? "Unknown", studentName: student.name ?? "Unknown",
averageMastery, averageMastery,
totalKnowledgePoints: allMastery.length, totalKnowledgePoints: allMastery.length,
strengths: allMastery.filter((m) => m.masteryLevel >= 80), strengths,
weaknesses: allMastery.filter((m) => m.masteryLevel < 60), weaknesses,
allMastery, allMastery,
} }
} })
/** 从提交答案更新掌握度(正确率作为掌握度) */ /** 从提交答案更新掌握度(正确率作为掌握度) */
export async function updateMasteryFromSubmission(submissionId: string): Promise<void> { export async function updateMasteryFromSubmission(submissionId: string): Promise<void> {
const [submission] = await db const submission = await getExamSubmissionWithAnswers(submissionId)
.select({ studentId: examSubmissions.studentId })
.from(examSubmissions)
.where(eq(examSubmissions.id, submissionId))
.limit(1)
if (!submission) return if (!submission) return
const answers = await db const answers = submission.answers
.select({
questionId: submissionAnswers.questionId,
score: submissionAnswers.score,
})
.from(submissionAnswers)
.where(eq(submissionAnswers.submissionId, submissionId))
if (answers.length === 0) return if (answers.length === 0) return
const questionIds = Array.from(new Set(answers.map((a) => a.questionId))) const questionIds = Array.from(new Set(answers.map((a) => a.questionId)))
const kpLinks = await db const kpMap = await getKnowledgePointsForQuestions(questionIds)
.select({
questionId: questionsToKnowledgePoints.questionId, // Build a Map for O(1) answer lookup instead of find() in loop
knowledgePointId: questionsToKnowledgePoints.knowledgePointId, const answerByQuestionId = new Map(answers.map((a) => [a.questionId, a]))
})
.from(questionsToKnowledgePoints)
.where(inArray(questionsToKnowledgePoints.questionId, questionIds))
const kpStats = new Map<string, { total: number; correct: number }>() const kpStats = new Map<string, { total: number; correct: number }>()
for (const link of kpLinks) { for (const [questionId, kpLinks] of kpMap.entries()) {
const answer = answers.find((a) => a.questionId === link.questionId) const answer = answerByQuestionId.get(questionId)
if (!answer) continue if (!answer) continue
for (const link of kpLinks) {
const stat = kpStats.get(link.knowledgePointId) ?? { total: 0, correct: 0 } const stat = kpStats.get(link.knowledgePointId) ?? { total: 0, correct: 0 }
stat.total += 1 stat.total += 1
if ((answer.score ?? 0) > 0) stat.correct += 1 if ((answer.score ?? 0) > 0) stat.correct += 1
kpStats.set(link.knowledgePointId, stat) kpStats.set(link.knowledgePointId, stat)
} }
}
const now = new Date() const now = new Date()
for (const [kpId, stat] of kpStats.entries()) { for (const [kpId, stat] of kpStats.entries()) {
@@ -147,22 +141,21 @@ export async function updateMasteryFromSubmission(submissionId: string): Promise
} }
/** 获取班级掌握度摘要 */ /** 获取班级掌握度摘要 */
export async function getClassMasterySummary(classId: string): Promise<ClassMasterySummary | null> { export const getClassMasterySummary = cache(async (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) const classExists = await getClassExists(classId)
if (!classRow) return null if (!classExists) return null
const className = (await getClassNameById(classId)) ?? "Unknown"
const students = await db const studentIds = await getActiveStudentIdsByClassId(classId)
.select({ id: users.id, name: users.name }) if (studentIds.length === 0) {
.from(classEnrollments) return { classId, className, studentCount: 0, averageMastery: 0, knowledgePointStats: [], studentsNeedingAttention: [] }
.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 = 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 const masteryRows = await db
.select({ mastery: knowledgePointMastery, kpName: knowledgePoints.name }) .select({ mastery: knowledgePointMastery, kpName: knowledgePoints.name })
.from(knowledgePointMastery) .from(knowledgePointMastery)
@@ -203,25 +196,25 @@ export async function getClassMasterySummary(classId: string): Promise<ClassMast
const studentsNeedingAttention = students const studentsNeedingAttention = students
.map((s) => { .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 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 } 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) .filter((s) => s.averageMastery < 60)
.sort((a, b) => a.averageMastery - b.averageMastery) .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[] = [] let studentIds: string[] = []
if (classId) { if (classId) {
const rows = await db.select({ studentId: classEnrollments.studentId }).from(classEnrollments).where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active"))) studentIds = await getActiveStudentIdsByClassId(classId)
studentIds = rows.map((r) => r.studentId)
} else if (gradeId) { } else if (gradeId) {
const rows = await db.select({ id: users.id }).from(users).where(eq(users.gradeId, gradeId)) studentIds = await getUserIdsByGradeId(gradeId)
studentIds = rows.map((r) => r.id)
} }
if (studentIds.length === 0) return [] if (studentIds.length === 0) return []
@@ -251,4 +244,4 @@ export async function getKnowledgePointStats(classId?: string, gradeId?: string)
notMasteredCount: e.notMastered, notMasteredCount: e.notMastered,
totalStudents: studentIds.length, totalStudents: studentIds.length,
})) }))
} })

View 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>

View File

@@ -1,7 +1,7 @@
import "server-only" import "server-only"
import { createId } from "@paralleldrive/cuid2" 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 { db } from "@/shared/db"
import { import {
@@ -11,18 +11,24 @@ import {
import type { CourseSelectionStatus } from "./types" 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<{ export async function runLottery(courseId: string): Promise<{
enrolled: number enrolled: number
waitlist: number waitlist: number
}> { }> {
const [course] = await db const [courseRows, selections] = await Promise.all([
db
.select() .select()
.from(electiveCourses) .from(electiveCourses)
.where(eq(electiveCourses.id, courseId)) .where(eq(electiveCourses.id, courseId))
.limit(1) .limit(1),
if (!course) throw new Error("Course not found") db
const selections = await db
.select() .select()
.from(courseSelections) .from(courseSelections)
.where( .where(
@@ -31,7 +37,10 @@ export async function runLottery(courseId: string): Promise<{
eq(courseSelections.status, "selected") 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) { if (selections.length === 0) {
return { enrolled: 0, waitlist: 0 } return { enrolled: 0, waitlist: 0 }
@@ -41,39 +50,46 @@ export async function runLottery(courseId: string): Promise<{
const capacity = course.capacity const capacity = course.capacity
const now = new Date() const now = new Date()
let enrolledCount = 0 const enrolledIds: string[] = []
let waitlistCount = 0 const waitlistIds: string[] = []
for (let i = 0; i < shuffled.length; i++) { for (let i = 0; i < shuffled.length; i++) {
const sel = shuffled[i]
const rank = i + 1
if (i < capacity) { if (i < capacity) {
await db enrolledIds.push(shuffled[i].id)
.update(courseSelections)
.set({
status: "enrolled",
lotteryRank: rank,
enrolledAt: now,
updatedAt: now,
})
.where(eq(courseSelections.id, sel.id))
enrolledCount++
} else { } else {
await db waitlistIds.push(shuffled[i].id)
.update(courseSelections)
.set({
status: "waitlist",
lotteryRank: rank,
updatedAt: now,
})
.where(eq(courseSelections.id, sel.id))
waitlistCount++
} }
} }
await db 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) .update(electiveCourses)
.set({ enrolledCount, status: "closed", updatedAt: now }) .set({ enrolledCount, status: "closed", updatedAt: now })
.where(eq(electiveCourses.id, courseId)) .where(eq(electiveCourses.id, courseId))
})
return { enrolled: enrolledCount, waitlist: waitlistCount } return { enrolled: enrolledCount, waitlist: waitlistCount }
} }
@@ -83,11 +99,25 @@ export async function selectCourse(
studentId: string, studentId: string,
priority?: number priority?: number
): Promise<{ status: CourseSelectionStatus; message: string }> { ): Promise<{ status: CourseSelectionStatus; message: string }> {
const [course] = await db const [courseRows, existingRows] = await Promise.all([
db
.select() .select()
.from(electiveCourses) .from(electiveCourses)
.where(eq(electiveCourses.id, courseId)) .where(eq(electiveCourses.id, courseId))
.limit(1) .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) throw new Error("Course not found")
if (course.status !== "open") throw new Error("Course selection is not open") 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") throw new Error("Selection has ended")
} }
const [existing] = await db const existing = existingRows[0]
.select()
.from(courseSelections)
.where(
and(
eq(courseSelections.courseId, courseId),
eq(courseSelections.studentId, studentId),
inArray(courseSelections.status, ["selected", "enrolled", "waitlist"])
)
)
.limit(1)
if (existing) throw new Error("Already selected this course") if (existing) throw new Error("Already selected this course")
const id = createId() const id = createId()
@@ -155,7 +175,8 @@ export async function dropCourse(
courseId: string, courseId: string,
studentId: string studentId: string
): Promise<void> { ): Promise<void> {
const [existing] = await db const [existingRows, courseRows] = await Promise.all([
db
.select() .select()
.from(courseSelections) .from(courseSelections)
.where( .where(
@@ -165,9 +186,17 @@ export async function dropCourse(
inArray(courseSelections.status, ["selected", "enrolled", "waitlist"]) 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") if (!existing) throw new Error("No active selection found")
const course = courseRows[0]
const now = new Date() const now = new Date()
await db await db
.update(courseSelections) .update(courseSelections)
@@ -175,11 +204,6 @@ export async function dropCourse(
.where(eq(courseSelections.id, existing.id)) .where(eq(courseSelections.id, existing.id))
if (existing.status === "enrolled") { if (existing.status === "enrolled") {
const [course] = await db
.select()
.from(electiveCourses)
.where(eq(electiveCourses.id, courseId))
.limit(1)
if (course && course.selectionMode === "fcfs") { if (course && course.selectionMode === "fcfs") {
const newEnrolledCount = Math.max(0, course.enrolledCount - 1) const newEnrolledCount = Math.max(0, course.enrolledCount - 1)
await db await db

View File

@@ -1,36 +1,53 @@
import "server-only" import "server-only"
import { cache } from "react"
import { and, asc, desc, eq, sql, type SQL } from "drizzle-orm" import { and, asc, desc, eq, sql, type SQL } from "drizzle-orm"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { import {
classes,
classEnrollments,
courseSelections, courseSelections,
electiveCourses, electiveCourses,
grades,
subjects,
users,
} from "@/shared/db/schema" } 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 { import type {
CourseSelectionStatus,
CourseSelectionWithDetails, CourseSelectionWithDetails,
ElectiveCourseStatus,
ElectiveCourseWithDetails, ElectiveCourseWithDetails,
} from "./types" } 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 => const toIso = (d: Date | null | undefined): string | null =>
d ? d.toISOString() : null d ? d.toISOString() : null
const toIsoRequired = (d: Date): string => d.toISOString() const toIsoRequired = (d: Date): string => d.toISOString()
const mapCourseRow = ( const mapCourseRow = (
r: typeof electiveCourses.$inferSelect & { r: CourseCoreRow,
teacherName: string | null teacherNames: Map<string, string | null>,
subjectName: string | null subjectNames: Map<string, string>,
gradeName: string | null gradeNames: Map<string, string>
}
): ElectiveCourseWithDetails => ({ ): ElectiveCourseWithDetails => ({
id: r.id, id: r.id,
name: r.name, name: r.name,
@@ -51,12 +68,34 @@ const mapCourseRow = (
credit: String(r.credit), credit: String(r.credit),
createdAt: toIsoRequired(r.createdAt), createdAt: toIsoRequired(r.createdAt),
updatedAt: toIsoRequired(r.updatedAt), updatedAt: toIsoRequired(r.updatedAt),
teacherName: r.teacherName, teacherName: r.teacherId ? (teacherNames.get(r.teacherId) ?? null) : null,
subjectName: r.subjectName, subjectName: r.subjectId ? (subjectNames.get(r.subjectId) ?? null) : null,
gradeName: r.gradeName, 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 db
.select({ .select({
id: electiveCourses.id, id: electiveCourses.id,
@@ -78,43 +117,10 @@ const buildCourseSelect = () =>
credit: electiveCourses.credit, credit: electiveCourses.credit,
createdAt: electiveCourses.createdAt, createdAt: electiveCourses.createdAt,
updatedAt: electiveCourses.updatedAt, updatedAt: electiveCourses.updatedAt,
teacherName: users.name,
subjectName: subjects.name,
gradeName: grades.name,
}) })
.from(electiveCourses) .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 = ( const buildSelectionCoreSelect = () =>
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 = () =>
db db
.select({ .select({
id: courseSelections.id, id: courseSelections.id,
@@ -129,52 +135,80 @@ const selectionDetailSelect = () =>
createdAt: courseSelections.createdAt, createdAt: courseSelections.createdAt,
updatedAt: courseSelections.updatedAt, updatedAt: courseSelections.updatedAt,
courseName: electiveCourses.name, courseName: electiveCourses.name,
studentName: users.name,
courseCapacity: electiveCourses.capacity, courseCapacity: electiveCourses.capacity,
courseEnrolledCount: electiveCourses.enrolledCount, courseEnrolledCount: electiveCourses.enrolledCount,
courseStatus: electiveCourses.status, courseStatus: electiveCourses.status,
}) })
.from(courseSelections) .from(courseSelections)
.leftJoin(electiveCourses, eq(electiveCourses.id, courseSelections.courseId)) .leftJoin(electiveCourses, eq(electiveCourses.id, courseSelections.courseId))
.leftJoin(users, eq(users.id, courseSelections.studentId))
export async function getCourseSelections( 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(),
])
const teacherNames = new Map<string, string | null>()
for (const [id, user] of userMap.entries()) {
teacherNames.set(id, user.name)
}
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 courseId: string
): Promise<CourseSelectionWithDetails[]> { ): Promise<CourseSelectionWithDetails[]> => {
const rows = await selectionDetailSelect() const rows = await buildSelectionCoreSelect()
.where(eq(courseSelections.courseId, courseId)) .where(eq(courseSelections.courseId, courseId))
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt)) .orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))
return rows.map(mapSelectionRow) const studentNames = await resolveStudentDisplayNames(rows)
return rows.map((r) => mapSelectionRow(r, studentNames))
} }
)
export async function getStudentSelections( export const getStudentSelections = cache(
async (
studentId: string studentId: string
): Promise<CourseSelectionWithDetails[]> { ): Promise<CourseSelectionWithDetails[]> => {
const rows = await selectionDetailSelect() const rows = await buildSelectionCoreSelect()
.where(eq(courseSelections.studentId, studentId)) .where(eq(courseSelections.studentId, studentId))
.orderBy(desc(courseSelections.selectedAt)) .orderBy(desc(courseSelections.selectedAt))
return rows.map(mapSelectionRow) const studentNames = await resolveStudentDisplayNames(rows)
return rows.map((r) => mapSelectionRow(r, studentNames))
} }
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( export const getStudentGradeId = cache(async (studentId: string): Promise<string | null> => {
return getStudentActiveGradeId(studentId)
})
export const getAvailableCoursesForStudent = cache(
async (
studentId: string, studentId: string,
gradeId?: string | null gradeId?: string | null
): Promise<ElectiveCourseWithDetails[]> { ): Promise<ElectiveCourseWithDetails[]> => {
const resolvedGradeId = gradeId ?? (await getStudentGradeId(studentId)) const resolvedGradeId = gradeId ?? (await getStudentGradeId(studentId))
const conditions: SQL[] = [eq(electiveCourses.status, "open")] const conditions: SQL[] = [eq(electiveCourses.status, "open")]
if (resolvedGradeId) { if (resolvedGradeId) {
@@ -182,8 +216,10 @@ export async function getAvailableCoursesForStudent(
sql`(${electiveCourses.gradeId} = ${resolvedGradeId} OR ${electiveCourses.gradeId} IS NULL)` sql`(${electiveCourses.gradeId} = ${resolvedGradeId} OR ${electiveCourses.gradeId} IS NULL)`
) )
} }
const rows = await buildCourseSelect() const rows = await buildCourseCoreSelect()
.where(and(...conditions)) .where(and(...conditions))
.orderBy(desc(electiveCourses.createdAt)) .orderBy(desc(electiveCourses.createdAt))
return rows.map(mapCourseRow) const displayMaps = await resolveCourseDisplayNames(rows)
return rows.map((r) => mapCourseRow(r, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames))
} }
)

View File

@@ -14,7 +14,6 @@ import {
import type { DataScope } from "@/shared/types/permissions" import type { DataScope } from "@/shared/types/permissions"
import type { import type {
ElectiveCourseStatus,
ElectiveCourseWithDetails, ElectiveCourseWithDetails,
GetElectiveCoursesParams, GetElectiveCoursesParams,
} from "./types" } from "./types"
@@ -114,7 +113,7 @@ export const getElectiveCourses = cache(
const conditions: SQL[] = [] const conditions: SQL[] = []
if (params?.status) if (params?.status)
conditions.push( 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?.gradeId) conditions.push(eq(electiveCourses.gradeId, params.gradeId))
if (params?.subjectId) if (params?.subjectId)
@@ -133,7 +132,8 @@ export const getElectiveCourses = cache(
).orderBy(desc(electiveCourses.createdAt)) ).orderBy(desc(electiveCourses.createdAt))
return rows.map(mapCourseRow) return rows.map(mapCourseRow)
} catch { } catch (error) {
console.error("getElectiveCourses failed:", error)
return [] return []
} }
} }
@@ -147,7 +147,8 @@ export const getElectiveCourseById = cache(
.limit(1) .limit(1)
if (!row) return null if (!row) return null
return mapCourseRow(row) return mapCourseRow(row)
} catch { } catch (error) {
console.error("getElectiveCourseById failed:", error)
return null return null
} }
} }
@@ -234,7 +235,8 @@ export async function getSubjectOptions(): Promise<{ id: string; name: string }[
.from(subjects) .from(subjects)
.orderBy(asc(subjects.order), asc(subjects.name)) .orderBy(asc(subjects.order), asc(subjects.name))
return rows.map((r) => ({ id: r.id, name: r.name })) return rows.map((r) => ({ id: r.id, name: r.name }))
} catch { } catch (error) {
console.error("getSubjectOptions failed:", error)
return [] return []
} }
} }

View File

@@ -17,10 +17,10 @@ export const CourseSelectionStatusEnum = z.enum([
"rejected", "rejected",
]) ])
const emptyToNull = (v: string | undefined | null) => const emptyToNull = (v: string | undefined | null): string | null =>
v && v.length > 0 ? v : 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) v === undefined ? undefined : emptyToNull(v)
export const CreateElectiveCourseSchema = z export const CreateElectiveCourseSchema = z

View File

@@ -1,7 +1,7 @@
"use server" "use server"
import { revalidatePath } from "next/cache" 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 { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions" import { Permissions } from "@/shared/types/permissions"
import { z } from "zod" import { z } from "zod"
@@ -267,7 +267,8 @@ export async function createExamAction(
try { try {
const ctx = await requirePermission(Permissions.EXAM_CREATE) 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({ const parsed = ExamCreateSchema.safeParse({
title: getStringValue(formData, "title"), title: getStringValue(formData, "title"),
@@ -346,9 +347,12 @@ export async function createAiExamAction(
try { try {
const ctx = await requirePermission(Permissions.EXAM_AI_GENERATE) const ctx = await requirePermission(Permissions.EXAM_AI_GENERATE)
const rawQuestions = formData.get("questionsJson") as string | null const rawQuestionsValue = formData.get("questionsJson")
const rawAiQuestions = formData.get("aiQuestionsJson") as string | null const rawQuestions = typeof rawQuestionsValue === "string" ? rawQuestionsValue : null
const rawStructure = formData.get("structureJson") as string | 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 aiSourceTextRaw = formData.get("aiSourceText")
const aiQuestionCountRaw = formData.get("aiQuestionCount") const aiQuestionCountRaw = formData.get("aiQuestionCount")
const aiProviderIdRaw = formData.get("aiProviderId") const aiProviderIdRaw = formData.get("aiProviderId")

View File

@@ -461,7 +461,9 @@ const splitStructureItems = (draft: z.infer<typeof AiStructureResponseSchema>) =
} satisfies SplitQuestionItem)) } satisfies SplitQuestionItem))
} }
const rows: SplitQuestionItem[] = [] const rows: SplitQuestionItem[] = []
draft.sections!.forEach((section, sectionIndex) => { const sections = draft.sections
if (sections) {
sections.forEach((section, sectionIndex) => {
section.questions.forEach((q) => { section.questions.forEach((q) => {
rows.push({ rows.push({
sectionIndex, sectionIndex,
@@ -471,6 +473,7 @@ const splitStructureItems = (draft: z.infer<typeof AiStructureResponseSchema>) =
}) })
}) })
}) })
}
return rows return rows
} }
@@ -654,15 +657,16 @@ const buildPreviewPayload = (
} }
): AiPreviewData => { ): AiPreviewData => {
const hasSections = Array.isArray(aiParsed.sections) && aiParsed.sections.length > 0 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 const limit = input.questionCount
let sections = aiParsed.sections let sections = aiParsed.sections
let flatQuestions = baseQuestions let flatQuestions = baseQuestions
if (typeof limit === "number" && limit > 0) { if (typeof limit === "number" && limit > 0) {
if (hasSections) { if (hasSections) {
const parsedSections = aiParsed.sections
let remaining = limit let remaining = limit
sections = aiParsed.sections!.map((s) => { sections = (parsedSections ?? []).map((s) => {
if (remaining <= 0) return { ...s, questions: [] } if (remaining <= 0) return { ...s, questions: [] }
const sliced = s.questions.slice(0, remaining) const sliced = s.questions.slice(0, remaining)
remaining -= sliced.length remaining -= sliced.length

View File

@@ -86,15 +86,16 @@ export function ExamAssembly(props: ExamAssemblyProps) {
pageSize: 20 pageSize: 20
}) })
if (result && result.data) { if (result.success && result.data) {
const questionsList = result.data.data
setBankQuestions(prev => { setBankQuestions(prev => {
if (reset) return result.data if (reset) return questionsList
// Deduplicate just in case // Deduplicate just in case
const existingIds = new Set(prev.map(q => q.id)) 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] return [...prev, ...newQuestions]
}) })
setHasMore(result.data.length === 20) setHasMore(questionsList.length === 20)
setPage(nextPage) setPage(nextPage)
} }
} catch { } catch {

View File

@@ -1,8 +1,10 @@
import { db } from "@/shared/db" 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 { count, eq, desc, like, and, or, inArray } from "drizzle-orm"
import { cache } from "react" import { cache } from "react"
import { createId } from "@paralleldrive/cuid2" 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 { Exam, ExamDifficulty, ExamStatus } from "./types"
import type { AiGeneratedQuestion, AiGeneratedStructureNode } from "./ai-pipeline" 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 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 => { const toExamDifficulty = (n: number | undefined): ExamDifficulty => {
if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) return n if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) return n
return 1 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) { if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
// Teacher can see exams for grades their classes belong to // Teacher can see exams for grades their classes belong to
const teacherGradeIds = await db const classGradeMap = await getClassGradeIdsByClassIds(params.scope.classIds)
.selectDistinct({ gradeId: classes.gradeId }) const gradeIds = Array.from(new Set(classGradeMap.values()))
.from(classes)
.where(inArray(classes.id, params.scope.classIds))
const gradeIds = teacherGradeIds.map(g => g.gradeId).filter(Boolean) as string[]
if (gradeIds.length > 0) { if (gradeIds.length > 0) {
conditions.push(inArray(exams.gradeId, gradeIds)) conditions.push(inArray(exams.gradeId, gradeIds))
} }
@@ -105,7 +110,7 @@ export const getExams = cache(async (params: GetExamsParams & { scope: DataScope
return { return {
id: exam.id, id: exam.id,
title: exam.title, title: exam.title,
status: (exam.status as ExamStatus) || "draft", status: toExamStatus(exam.status),
subject: exam.subject?.name ?? getString(meta, "subject") ?? "General", subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General", grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
difficulty: toExamDifficulty(getNumber(meta, "difficulty")), difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
@@ -153,11 +158,8 @@ export const getExamById = cache(async (id: string, scope?: DataScope) => {
return null return null
} }
if (scope.type === "class_taught" && scope.classIds.length > 0) { if (scope.type === "class_taught" && scope.classIds.length > 0) {
const teacherGradeIds = await db const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
.selectDistinct({ gradeId: classes.gradeId }) const gradeIds = Array.from(new Set(classGradeMap.values()))
.from(classes)
.where(inArray(classes.id, scope.classIds))
const gradeIds = teacherGradeIds.map(g => g.gradeId).filter(Boolean) as string[]
if (gradeIds.length > 0 && !gradeIds.includes(exam.gradeId ?? "")) { if (gradeIds.length > 0 && !gradeIds.includes(exam.gradeId ?? "")) {
return null return null
} }
@@ -169,7 +171,7 @@ export const getExamById = cache(async (id: string, scope?: DataScope) => {
return { return {
id: exam.id, id: exam.id,
title: exam.title, title: exam.title,
status: (exam.status as ExamStatus) || "draft", status: toExamStatus(exam.status),
subject: exam.subject?.name ?? getString(meta, "subject") ?? "General", subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General", grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
difficulty: toExamDifficulty(getNumber(meta, "difficulty")), 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 => { export const omitScheduledAtFromDescription = (description: string | null): string => {
if (!description) return "{}" if (!description) return "{}"
try { try {
const meta = JSON.parse(description) const parsed: unknown = JSON.parse(description)
if (typeof meta === "object" && meta !== null) { if (isRecord(parsed)) {
const rest = { ...(meta as Record<string, unknown>) } const rest = { ...parsed }
delete rest.scheduledAt delete rest.scheduledAt
return JSON.stringify(rest) return JSON.stringify(rest)
} }
@@ -299,8 +301,31 @@ export const persistAiGeneratedExamDraft = async (input: {
description: string description: string
structure: AiGeneratedStructureNode[] structure: AiGeneratedStructureNode[]
generated: AiGeneratedQuestion[] generated: AiGeneratedQuestion[]
}) => { }): Promise<void> => {
const orderedQuestions = buildOrderedQuestionsFromStructure(input.structure, input.generated) 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 db.transaction(async (tx) => {
await tx.insert(exams).values({ await tx.insert(exams).values({
id: input.examId, id: input.examId,
@@ -314,21 +339,9 @@ export const persistAiGeneratedExamDraft = async (input: {
structure: input.structure, structure: input.structure,
}) })
if (input.generated.length > 0) { if (remappedOrderedQuestions.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) {
await tx.insert(examQuestions).values( await tx.insert(examQuestions).values(
orderedQuestions.map((q, idx) => ({ remappedOrderedQuestions.map((q, idx) => ({
examId: input.examId, examId: input.examId,
questionId: q.id, questionId: q.id,
score: q.score ?? 0, score: q.score ?? 0,
@@ -354,11 +367,8 @@ export const getExamsDashboardStats = cache(async (scope?: DataScope): Promise<E
conditions.push(inArray(exams.gradeId, scope.gradeIds)) conditions.push(inArray(exams.gradeId, scope.gradeIds))
} }
if (scope.type === "class_taught" && scope.classIds.length > 0) { if (scope.type === "class_taught" && scope.classIds.length > 0) {
const teacherGradeIds = await db const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
.selectDistinct({ gradeId: classes.gradeId }) const gradeIds = Array.from(new Set(classGradeMap.values()))
.from(classes)
.where(inArray(classes.id, scope.classIds))
const gradeIds = teacherGradeIds.map((g) => g.gradeId).filter(Boolean) as string[]
if (gradeIds.length > 0) { if (gradeIds.length > 0) {
conditions.push(inArray(exams.gradeId, gradeIds)) 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 })) 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
}

View File

@@ -1,6 +1,7 @@
import "server-only" import "server-only"
import { and, count, desc, eq, inArray, like, or, sql } from "drizzle-orm" import { and, count, desc, eq, inArray, like, or, sql } from "drizzle-orm"
import { cache } from "react"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { fileAttachments } from "@/shared/db/schema" import { fileAttachments } from "@/shared/db/schema"
@@ -50,7 +51,8 @@ export async function createFileAttachment(
const created = await getFileAttachment(data.id) const created = await getFileAttachment(data.id)
return created return created
} catch { } catch (error) {
console.error("createFileAttachment failed:", error)
return null return null
} }
} }
@@ -58,7 +60,8 @@ export async function createFileAttachment(
/** /**
* 按 ID 查询文件附件 * 按 ID 查询文件附件
*/ */
export async function getFileAttachment(id: string): Promise<FileAttachment | null> { export const getFileAttachment = cache(
async (id: string): Promise<FileAttachment | null> => {
try { try {
const [row] = await db const [row] = await db
.select() .select()
@@ -67,18 +70,18 @@ export async function getFileAttachment(id: string): Promise<FileAttachment | nu
.limit(1) .limit(1)
return row ? mapRow(row) : null return row ? mapRow(row) : null
} catch { } catch (error) {
console.error("getFileAttachment failed:", error)
return null return null
} }
} },
)
/** /**
* 按关联资源查询文件列表 * 按关联资源查询文件列表
*/ */
export async function getFileAttachmentsByTarget( export const getFileAttachmentsByTarget = cache(
targetType: string, async (targetType: string, targetId: string): Promise<FileAttachment[]> => {
targetId: string
): Promise<FileAttachment[]> {
try { try {
const rows = await db const rows = await db
.select() .select()
@@ -92,17 +95,18 @@ export async function getFileAttachmentsByTarget(
.orderBy(desc(fileAttachments.createdAt)) .orderBy(desc(fileAttachments.createdAt))
return rows.map(mapRow) return rows.map(mapRow)
} catch { } catch (error) {
console.error("getFileAttachmentsByTarget failed:", error)
return [] return []
} }
} },
)
/** /**
* 按上传者查询文件列表 * 按上传者查询文件列表
*/ */
export async function getFileAttachmentsByUploader( export const getFileAttachmentsByUploader = cache(
uploaderId: string async (uploaderId: string): Promise<FileAttachment[]> => {
): Promise<FileAttachment[]> {
try { try {
const rows = await db const rows = await db
.select() .select()
@@ -111,15 +115,18 @@ export async function getFileAttachmentsByUploader(
.orderBy(desc(fileAttachments.createdAt)) .orderBy(desc(fileAttachments.createdAt))
return rows.map(mapRow) return rows.map(mapRow)
} catch { } catch (error) {
console.error("getFileAttachmentsByUploader failed:", error)
return [] return []
} }
} },
)
/** /**
* 查询所有文件(用于管理员文件管理页面) * 查询所有文件(用于管理员文件管理页面)
*/ */
export async function getAllFileAttachments(limit = 100): Promise<FileAttachment[]> { export const getAllFileAttachments = cache(
async (limit = 100): Promise<FileAttachment[]> => {
try { try {
const rows = await db const rows = await db
.select() .select()
@@ -128,10 +135,12 @@ export async function getAllFileAttachments(limit = 100): Promise<FileAttachment
.limit(limit) .limit(limit)
return rows.map(mapRow) return rows.map(mapRow)
} catch { } catch (error) {
console.error("getAllFileAttachments failed:", error)
return [] return []
} }
} },
)
/** /**
* 删除文件附件记录 * 删除文件附件记录
@@ -140,7 +149,8 @@ export async function deleteFileAttachment(id: string): Promise<boolean> {
try { try {
await db.delete(fileAttachments).where(eq(fileAttachments.id, id)) await db.delete(fileAttachments).where(eq(fileAttachments.id, id))
return true return true
} catch { } catch (error) {
console.error("deleteFileAttachment failed:", error)
return false return false
} }
} }
@@ -156,7 +166,8 @@ export async function deleteFileAttachments(ids: string[]): Promise<BatchDeleteR
try { try {
await db.delete(fileAttachments).where(inArray(fileAttachments.id, ids)) await db.delete(fileAttachments).where(inArray(fileAttachments.id, ids))
return { success: true, deletedCount: ids.length, failedIds: [] } return { success: true, deletedCount: ids.length, failedIds: [] }
} catch { } catch (error) {
console.error("deleteFileAttachments batch failed:", error)
// 失败时回退到逐条删除,尽量多删 // 失败时回退到逐条删除,尽量多删
const failedIds: string[] = [] const failedIds: string[] = []
let deletedCount = 0 let deletedCount = 0
@@ -164,7 +175,8 @@ export async function deleteFileAttachments(ids: string[]): Promise<BatchDeleteR
try { try {
await db.delete(fileAttachments).where(eq(fileAttachments.id, id)) await db.delete(fileAttachments).where(eq(fileAttachments.id, id))
deletedCount += 1 deletedCount += 1
} catch { } catch (err) {
console.error("deleteFileAttachments single failed:", err)
failedIds.push(id) failedIds.push(id)
} }
} }
@@ -181,9 +193,8 @@ export async function deleteFileAttachments(ids: string[]): Promise<BatchDeleteR
* - mimeType: 精确匹配或前缀匹配(如 "image/" * - mimeType: 精确匹配或前缀匹配(如 "image/"
* - search: 在 originalName / filename 中模糊匹配 * - search: 在 originalName / filename 中模糊匹配
*/ */
export async function getFileAttachmentsWithFilters( export const getFileAttachmentsWithFilters = cache(
params: FileAttachmentQueryParams async (params: FileAttachmentQueryParams): Promise<FileAttachment[]> => {
): Promise<FileAttachment[]> {
try { try {
const { mimeType, search, limit = 100, offset = 0 } = params const { mimeType, search, limit = 100, offset = 0 } = params
@@ -197,12 +208,11 @@ export async function getFileAttachmentsWithFilters(
} }
if (search) { if (search) {
const kw = `%${search}%` const kw = `%${search}%`
conditions.push( const nameCondition = or(
or(
like(fileAttachments.originalName, kw), like(fileAttachments.originalName, kw),
like(fileAttachments.filename, kw) like(fileAttachments.filename, kw)
)!
) )
if (nameCondition) conditions.push(nameCondition)
} }
const where = conditions.length > 0 ? and(...conditions) : undefined const where = conditions.length > 0 ? and(...conditions) : undefined
@@ -216,15 +226,18 @@ export async function getFileAttachmentsWithFilters(
.offset(offset) .offset(offset)
return rows.map(mapRow) return rows.map(mapRow)
} catch { } catch (error) {
console.error("getFileAttachmentsWithFilters failed:", error)
return [] return []
} }
} },
)
/** /**
* 获取文件统计信息(总数、总大小、按类型分组) * 获取文件统计信息(总数、总大小、按类型分组)
*/ */
export async function getFileStats(): Promise<FileStats> { export const getFileStats = cache(
async (): Promise<FileStats> => {
try { try {
const rows = await db const rows = await db
.select({ .select({
@@ -245,15 +258,18 @@ export async function getFileStats(): Promise<FileStats> {
const totalSize = byType.reduce((sum, r) => sum + r.size, 0) const totalSize = byType.reduce((sum, r) => sum + r.size, 0)
return { totalCount, totalSize, byType } return { totalCount, totalSize, byType }
} catch { } catch (error) {
console.error("getFileStats failed:", error)
return { totalCount: 0, totalSize: 0, byType: [] } return { totalCount: 0, totalSize: 0, byType: [] }
} }
} },
)
/** /**
* 按 ID 列表批量查询文件(用于批量删除前获取磁盘路径) * 按 ID 列表批量查询文件(用于批量删除前获取磁盘路径)
*/ */
export async function getFileAttachmentsByIds(ids: string[]): Promise<FileAttachment[]> { export const getFileAttachmentsByIds = cache(
async (ids: string[]): Promise<FileAttachment[]> => {
if (ids.length === 0) return [] if (ids.length === 0) return []
try { try {
const rows = await db const rows = await db
@@ -261,7 +277,9 @@ export async function getFileAttachmentsByIds(ids: string[]): Promise<FileAttach
.from(fileAttachments) .from(fileAttachments)
.where(inArray(fileAttachments.id, ids)) .where(inArray(fileAttachments.id, ids))
return rows.map(mapRow) return rows.map(mapRow)
} catch { } catch (error) {
console.error("getFileAttachmentsByIds failed:", error)
return [] return []
} }
} },
)

View File

@@ -1,13 +1,15 @@
import "server-only" import "server-only"
import { cache } from "react"
import { and, asc, eq, inArray, sql } from "drizzle-orm" import { and, asc, eq, inArray, sql } from "drizzle-orm"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { gradeRecords } from "@/shared/db/schema"
import { import {
classes, getClassesByGradeId,
gradeRecords, getClassNameById,
subjects, } from "@/modules/classes/data-access"
} from "@/shared/db/schema" import { getSubjectOptions } from "@/modules/school/data-access"
import type { DataScope } from "@/shared/types/permissions" import type { DataScope } from "@/shared/types/permissions"
import type { import type {
@@ -54,9 +56,8 @@ export interface GradeTrendParams {
currentUserId?: string currentUserId?: string
} }
export async function getGradeTrend( export const getGradeTrend = cache(
params: GradeTrendParams async (params: GradeTrendParams): Promise<GradeTrendResult | null> => {
): Promise<GradeTrendResult | null> {
const conditions = [eq(gradeRecords.classId, params.classId)] const conditions = [eq(gradeRecords.classId, params.classId)]
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId)) if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId)) if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId))
@@ -72,17 +73,22 @@ export async function getGradeTrend(
const rows = await db const rows = await db
.select({ .select({
record: gradeRecords, record: gradeRecords,
className: classes.name,
subjectName: subjects.name,
}) })
.from(gradeRecords) .from(gradeRecords)
.leftJoin(classes, eq(classes.id, gradeRecords.classId))
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
.where(and(...conditions)) .where(and(...conditions))
.orderBy(asc(gradeRecords.createdAt)) .orderBy(asc(gradeRecords.createdAt))
if (rows.length === 0) return null 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 points: GradeTrendPoint[] = rows.map((r) => {
const score = toNumber(r.record.score) const score = toNumber(r.record.score)
const fullScore = toNumber(r.record.fullScore) const fullScore = toNumber(r.record.fullScore)
@@ -97,20 +103,20 @@ export async function getGradeTrend(
}) })
const avg = points.reduce((acc, p) => acc + p.normalizedScore, 0) / points.length const avg = points.reduce((acc, p) => acc + p.normalizedScore, 0) / points.length
const className = rows[0].className ?? "Class" const finalClassName = className ?? "Class"
const subjectName = rows[0].subjectName ?? "All Subjects"
const studentLabel = params.studentId const studentLabel = params.studentId
? `Student ${params.studentId.slice(-4)}` ? `Student ${params.studentId.slice(-4)}`
: "Class Average" : "Class Average"
return { return {
label: params.subjectId label: params.subjectId
? `${className} · ${subjectName} · ${studentLabel}` ? `${finalClassName} · ${subjectName} · ${studentLabel}`
: `${className} · ${studentLabel}`, : `${finalClassName} · ${studentLabel}`,
points, points,
averageScore: Math.round(avg * 100) / 100, averageScore: Math.round(avg * 100) / 100,
} }
} }
)
export interface ClassComparisonParams { export interface ClassComparisonParams {
gradeId: string gradeId: string
@@ -119,37 +125,32 @@ export interface ClassComparisonParams {
scope: DataScope scope: DataScope
} }
export async function getClassComparison( export const getClassComparison = cache(
params: ClassComparisonParams async (params: ClassComparisonParams): Promise<ClassComparisonItem[]> => {
): Promise<ClassComparisonItem[]> { const classRows = await getClassesByGradeId(params.gradeId)
const classRows = await db
.select({ id: classes.id, name: classes.name })
.from(classes)
.where(eq(classes.gradeId, params.gradeId))
if (classRows.length === 0) return [] if (classRows.length === 0) return []
const scope = params.scope const scope = params.scope
const allowedClassIds = const scopeClassIdSet =
scope.type === "class_taught" scope.type === "class_taught" ? new Set(scope.classIds) : null
? classRows.filter((c) => scope.classIds.includes(c.id)).map((c) => c.id) const allowedClassRows = scopeClassIdSet
: classRows.map((c) => c.id) ? classRows.filter((c) => scopeClassIdSet.has(c.id))
: classRows
if (allowedClassIds.length === 0) return [] if (allowedClassRows.length === 0) return []
const result: ClassComparisonItem[] = [] const allowedClassIds = allowedClassRows.map((c) => c.id)
for (const cls of classRows) {
if (!allowedClassIds.includes(cls.id)) continue
const conditions = [ const conditions = [
eq(gradeRecords.classId, cls.id), inArray(gradeRecords.classId, allowedClassIds),
eq(gradeRecords.subjectId, params.subjectId), eq(gradeRecords.subjectId, params.subjectId),
] ]
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId)) if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
const rows = await db const allRows = await db
.select({ .select({
classId: gradeRecords.classId,
score: gradeRecords.score, score: gradeRecords.score,
fullScore: gradeRecords.fullScore, fullScore: gradeRecords.fullScore,
studentId: gradeRecords.studentId, studentId: gradeRecords.studentId,
@@ -157,35 +158,64 @@ export async function getClassComparison(
.from(gradeRecords) .from(gradeRecords)
.where(and(...conditions)) .where(and(...conditions))
if (rows.length === 0) { const byClass = new Map<string, typeof allRows>()
result.push({ for (const r of allRows) {
classId: cls.id, className: cls.name, averageScore: 0, medianScore: 0, const existing = byClass.get(r.classId)
passRate: 0, excellentRate: 0, count: 0, studentCount: 0, if (existing) {
}) existing.push(r)
continue } else {
byClass.set(r.classId, [r])
}
} }
const normalized = rows.map((r) => normalize(toNumber(r.score), toNumber(r.fullScore))) 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,
}
}
const normalized = rows.map((r) =>
normalize(toNumber(r.score), toNumber(r.fullScore))
)
const sorted = [...normalized].sort((a, b) => a - b) const sorted = [...normalized].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2) const mid = Math.floor(sorted.length / 2)
const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid] 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 avg = normalized.reduce((a, b) => a + b, 0) / normalized.length
const uniqueStudents = new Set(rows.map((r) => r.studentId)).size const uniqueStudents = new Set(rows.map((r) => r.studentId)).size
result.push({ 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, classId: cls.id,
className: cls.name, className: cls.name,
averageScore: Math.round(avg * 100) / 100, averageScore: Math.round(avg * 100) / 100,
medianScore: Math.round(median * 100) / 100, medianScore: Math.round(median * 100) / 100,
passRate: Math.round((normalized.filter((s) => s >= 60).length / normalized.length) * 10000) / 100, passRate: Math.round((passCount / normalized.length) * 10000) / 100,
excellentRate: Math.round((normalized.filter((s) => s >= 85).length / normalized.length) * 10000) / 100, excellentRate: Math.round((excellentCount / normalized.length) * 10000) / 100,
count: normalized.length, count: normalized.length,
studentCount: uniqueStudents, studentCount: uniqueStudents,
})
} }
})
return result return result
} }
)
export interface SubjectComparisonParams { export interface SubjectComparisonParams {
classId: string classId: string
@@ -193,9 +223,8 @@ export interface SubjectComparisonParams {
scope: DataScope scope: DataScope
} }
export async function getSubjectComparison( export const getSubjectComparison = cache(
params: SubjectComparisonParams async (params: SubjectComparisonParams): Promise<SubjectComparisonItem[]> => {
): Promise<SubjectComparisonItem[]> {
const scopeFilter = buildScopeClassFilter(params.scope) const scopeFilter = buildScopeClassFilter(params.scope)
const conditions = [eq(gradeRecords.classId, params.classId)] const conditions = [eq(gradeRecords.classId, params.classId)]
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId)) if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
@@ -204,20 +233,26 @@ export async function getSubjectComparison(
const rows = await db const rows = await db
.select({ .select({
subjectId: gradeRecords.subjectId, subjectId: gradeRecords.subjectId,
subjectName: subjects.name,
score: gradeRecords.score, score: gradeRecords.score,
fullScore: gradeRecords.fullScore, fullScore: gradeRecords.fullScore,
}) })
.from(gradeRecords) .from(gradeRecords)
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
.where(and(...conditions)) .where(and(...conditions))
if (rows.length === 0) return []
// 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[] }>() const bySubject = new Map<string, { name: string; scores: number[] }>()
for (const r of rows) { for (const r of rows) {
const sid = r.subjectId const sid = r.subjectId
if (!sid) continue if (!sid) continue
const entry = bySubject.get(sid) ?? { name: r.subjectName ?? "Unknown", scores: [] } const entry = bySubject.get(sid) ?? { name: subjectNameById.get(sid) ?? "Unknown", scores: [] }
entry.scores.push(normalize(toNumber(r.score), toNumber(r.fullScore))) entry.scores.push(normalize(toNumber(r.score), toNumber(r.fullScore)))
bySubject.set(sid, entry) bySubject.set(sid, entry)
} }
@@ -227,22 +262,32 @@ export async function getSubjectComparison(
if (entry.scores.length === 0) continue if (entry.scores.length === 0) continue
const sorted = [...entry.scores].sort((a, b) => a - b) const sorted = [...entry.scores].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2) const mid = Math.floor(sorted.length / 2)
const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid] 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 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({ result.push({
subjectId, subjectId,
subjectName: entry.name, subjectName: entry.name,
averageScore: Math.round(avg * 100) / 100, averageScore: Math.round(avg * 100) / 100,
medianScore: Math.round(median * 100) / 100, medianScore: Math.round(median * 100) / 100,
passRate: Math.round((entry.scores.filter((s) => s >= 60).length / entry.scores.length) * 10000) / 100, passRate: Math.round((passCount / entry.scores.length) * 10000) / 100,
excellentRate: Math.round((entry.scores.filter((s) => s >= 85).length / entry.scores.length) * 10000) / 100, excellentRate: Math.round((excellentCount / entry.scores.length) * 10000) / 100,
count: entry.scores.length, count: entry.scores.length,
}) })
} }
return result.sort((a, b) => b.averageScore - a.averageScore) return result.sort((a, b) => b.averageScore - a.averageScore)
} }
)
export interface GradeDistributionParams { export interface GradeDistributionParams {
classId: string classId: string
@@ -252,9 +297,8 @@ export interface GradeDistributionParams {
currentUserId?: string currentUserId?: string
} }
export async function getGradeDistribution( export const getGradeDistribution = cache(
params: GradeDistributionParams async (params: GradeDistributionParams): Promise<GradeDistributionResult> => {
): Promise<GradeDistributionResult> {
const conditions = [eq(gradeRecords.classId, params.classId)] const conditions = [eq(gradeRecords.classId, params.classId)]
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId)) if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId)) if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
@@ -291,3 +335,4 @@ export async function getGradeDistribution(
return { buckets, totalCount: rows.length } return { buckets, totalCount: rows.length }
} }
)

View File

@@ -1,13 +1,12 @@
import "server-only" import "server-only"
import { cache } from "react"
import { and, asc, eq } from "drizzle-orm" import { and, asc, eq } from "drizzle-orm"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { import { gradeRecords } from "@/shared/db/schema"
classEnrollments, import { getStudentActiveClassId } from "@/modules/classes/data-access"
gradeRecords, import { getUserNamesByIds } from "@/modules/users/data-access"
users,
} from "@/shared/db/schema"
import type { import type {
RankingTrendPoint, RankingTrendPoint,
@@ -29,38 +28,28 @@ const normalize = (score: number, fullScore: number): number => {
* Each point represents one assessment (grouped by title), with the * Each point represents one assessment (grouped by title), with the
* student's normalized score, rank, and total participants. * student's normalized score, rank, and total participants.
*/ */
export async function getRankingTrend( export const getRankingTrend = cache(
async (
studentId: string, studentId: string,
subjectId?: string, subjectId?: string,
semester?: "1" | "2" semester?: "1" | "2"
): Promise<RankingTrendResult | null> { ): Promise<RankingTrendResult | null> => {
const [student] = await db const studentNameMap = await getUserNamesByIds([studentId])
.select({ id: users.id, name: users.name }) const studentInfo = studentNameMap.get(studentId)
.from(users) if (!studentInfo) return null
.where(eq(users.id, studentId)) const studentName = studentInfo.name ?? "Unknown"
.limit(1)
if (!student) return null
const [enrollment] = await db const classId = await getStudentActiveClassId(studentId)
.select({ classId: classEnrollments.classId })
.from(classEnrollments)
.where(
and(
eq(classEnrollments.studentId, studentId),
eq(classEnrollments.status, "active")
)
)
.limit(1)
if (!enrollment) { if (!classId) {
return { return {
studentId, studentId,
studentName: student.name ?? "Unknown", studentName,
points: [], points: [],
} }
} }
const conditions = [eq(gradeRecords.classId, enrollment.classId)] const conditions = [eq(gradeRecords.classId, classId)]
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId)) if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
if (semester) conditions.push(eq(gradeRecords.semester, semester)) if (semester) conditions.push(eq(gradeRecords.semester, semester))
@@ -97,10 +86,18 @@ export async function getRankingTrend(
for (const [title, entry] of byTitle.entries()) { for (const [title, entry] of byTitle.entries()) {
if (entry.entries.length === 0) continue if (entry.entries.length === 0) continue
const sorted = [...entry.entries].sort((a, b) => b.normalized - a.normalized) const sorted = [...entry.entries].sort((a, b) => b.normalized - a.normalized)
const rank = sorted.findIndex((e) => e.studentId === studentId) + 1 // Single traversal: find rank and student entry together
if (rank <= 0) continue let rank = 0
const studentEntry = sorted.find((e) => e.studentId === studentId) let studentEntry: { studentId: string; normalized: number } | null = null
if (!studentEntry) continue 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({ points.push({
title, title,
@@ -115,7 +112,8 @@ export async function getRankingTrend(
return { return {
studentId, studentId,
studentName: student.name ?? "Unknown", studentName,
points, points,
} }
} }
)

View File

@@ -1,15 +1,19 @@
import "server-only" 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 { db } from "@/shared/db"
import { gradeRecords } from "@/shared/db/schema"
import { import {
classes, getActiveStudentIdsByClassId,
classEnrollments, getClassExists,
gradeRecords, getClassNameById,
subjects, getClassNamesByIds,
users, } from "@/modules/classes/data-access"
} from "@/shared/db/schema" import { getSubjectOptions } from "@/modules/school/data-access"
import { getUserNamesByIds } from "@/modules/users/data-access"
import type { DataScope } from "@/shared/types/permissions" import type { DataScope } from "@/shared/types/permissions"
import type { import type {
@@ -70,9 +74,10 @@ const buildScopeClassFilter = (scope: DataScope) => {
return sql`1=0` return sql`1=0`
} }
export async function getGradeRecords( export const getGradeRecords = cache(
async (
params: GradeQueryParams & { scope: DataScope; currentUserId?: string } params: GradeQueryParams & { scope: DataScope; currentUserId?: string }
): Promise<GradeRecordListItem[]> { ): Promise<GradeRecordListItem[]> => {
const conditions = [] const conditions = []
const scopeFilter = buildScopeClassFilter(params.scope) const scopeFilter = buildScopeClassFilter(params.scope)
@@ -92,37 +97,37 @@ export async function getGradeRecords(
const rows = await db const rows = await db
.select({ .select({
record: gradeRecords, record: gradeRecords,
studentName: users.name,
className: classes.name,
subjectName: subjects.name,
}) })
.from(gradeRecords) .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) .where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(gradeRecords.createdAt)) .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 recorderIds = Array.from(new Set(rows.map((r) => r.record.recordedBy)))
const recorderMap = new Map<string, string>()
if (recorderIds.length > 0) { const [studentNameMap, classNameMap, subjectOptions, recorderNameMap] = await Promise.all([
const recorders = await db getUserNamesByIds(studentIds),
.select({ id: users.id, name: users.name }) getClassNamesByIds(classIds),
.from(users) getSubjectOptions(),
.where(inArray(users.id, recorderIds)) getUserNamesByIds(recorderIds),
for (const r of recorders) { ])
recorderMap.set(r.id, r.name ?? "Unknown")
} const subjectNameById = new Map<string, string>()
} for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
return rows.map((r) => ({ return rows.map((r) => ({
id: r.record.id, id: r.record.id,
studentId: r.record.studentId, studentId: r.record.studentId,
studentName: r.studentName ?? "Unknown", studentName: studentNameMap.get(r.record.studentId)?.name ?? "Unknown",
classId: r.record.classId, classId: r.record.classId,
className: r.className ?? "Unknown", className: r.record.classId ? classNameMap.get(r.record.classId) ?? "Unknown" : "Unknown",
subjectId: r.record.subjectId, subjectId: r.record.subjectId,
subjectName: r.subjectName ?? "Unknown", subjectName: r.record.subjectId ? subjectNameById.get(r.record.subjectId) ?? "Unknown" : "Unknown",
examId: r.record.examId ?? null, examId: r.record.examId ?? null,
title: r.record.title, title: r.record.title,
score: toNumber(r.record.score), score: toNumber(r.record.score),
@@ -130,22 +135,22 @@ export async function getGradeRecords(
type: r.record.type, type: r.record.type,
semester: r.record.semester, semester: r.record.semester,
recordedBy: r.record.recordedBy, recordedBy: r.record.recordedBy,
recorderName: recorderMap.get(r.record.recordedBy) ?? "Unknown", recorderName: recorderNameMap.get(r.record.recordedBy)?.name ?? "Unknown",
remark: r.record.remark ?? null, remark: r.record.remark ?? null,
createdAt: r.record.createdAt.toISOString(), 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) const [row] = await db.select().from(gradeRecords).where(eq(gradeRecords.id, id)).limit(1)
return row ? serializeRecord(row) : null return row ? serializeRecord(row) : null
} })
export async function createGradeRecord( export async function createGradeRecord(
data: CreateGradeRecordInput, data: CreateGradeRecordInput,
recordedBy: string recordedBy: string
): Promise<string> { ): Promise<string> {
const { createId } = await import("@paralleldrive/cuid2")
const id = createId() const id = createId()
await db.insert(gradeRecords).values({ await db.insert(gradeRecords).values({
id, id,
@@ -169,7 +174,6 @@ export async function batchCreateGradeRecords(
data: BatchCreateGradeRecordInput, data: BatchCreateGradeRecordInput,
recordedBy: string recordedBy: string
): Promise<number> { ): Promise<number> {
const { createId } = await import("@paralleldrive/cuid2")
const rows = data.records.map((r) => ({ const rows = data.records.map((r) => ({
id: createId(), id: createId(),
studentId: r.studentId, studentId: r.studentId,
@@ -211,11 +215,12 @@ export async function deleteGradeRecord(id: string): Promise<void> {
await db.delete(gradeRecords).where(eq(gradeRecords.id, id)) await db.delete(gradeRecords).where(eq(gradeRecords.id, id))
} }
export async function getClassGradeStats( export const getClassGradeStats = cache(
async (
classId: string, classId: string,
subjectId?: string, subjectId?: string,
examId?: string examId?: string
): Promise<GradeStats | null> { ): Promise<GradeStats | null> => {
const conditions = [eq(gradeRecords.classId, classId)] const conditions = [eq(gradeRecords.classId, classId)]
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId)) if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
if (examId) conditions.push(eq(gradeRecords.examId, examId)) if (examId) conditions.push(eq(gradeRecords.examId, examId))
@@ -246,6 +251,7 @@ export async function getClassGradeStats(
let passCount = 0 let passCount = 0
let excellentCount = 0 let excellentCount = 0
for (let i = 0; i < countN; i++) { for (let i = 0; i < countN; i++) {
if (fullScores[i] <= 0) continue
const ratio = scores[i] / fullScores[i] const ratio = scores[i] / fullScores[i]
if (ratio >= 0.6) passCount++ if (ratio >= 0.6) passCount++
if (ratio >= 0.85) excellentCount++ if (ratio >= 0.85) excellentCount++
@@ -262,43 +268,53 @@ export async function getClassGradeStats(
count: countN, count: countN,
} }
} }
)
export async function getStudentGradeSummary( export async function getStudentGradeSummary(
studentId: string studentId: string
): Promise<StudentGradeSummary | null> { ): Promise<StudentGradeSummary | null> {
const [student] = await db.select({ name: users.name }).from(users).where(eq(users.id, studentId)).limit(1) const studentNameMap = await getUserNamesByIds([studentId])
if (!student) return null const studentName = studentNameMap.get(studentId)?.name ?? null
if (!studentName && !studentNameMap.has(studentId)) return null
const records = await db const records = await db
.select({ .select({
record: gradeRecords, record: gradeRecords,
className: classes.name,
subjectName: subjects.name,
}) })
.from(gradeRecords) .from(gradeRecords)
.leftJoin(classes, eq(classes.id, gradeRecords.classId))
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
.where(eq(gradeRecords.studentId, studentId)) .where(eq(gradeRecords.studentId, studentId))
.orderBy(desc(gradeRecords.createdAt)) .orderBy(desc(gradeRecords.createdAt))
if (records.length === 0) { if (records.length === 0) {
return { return {
studentId, studentId,
studentName: student.name ?? "Unknown", studentName: studentName ?? "Unknown",
records: [], records: [],
averageScore: 0, averageScore: 0,
rank: 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) => ({ const listItems: GradeRecordListItem[] = records.map((r) => ({
id: r.record.id, id: r.record.id,
studentId: r.record.studentId, studentId: r.record.studentId,
studentName: student.name ?? "Unknown", studentName: studentName ?? "Unknown",
classId: r.record.classId, classId: r.record.classId,
className: r.className ?? "Unknown", className: r.record.classId ? classNameMap.get(r.record.classId) ?? "Unknown" : "Unknown",
subjectId: r.record.subjectId, subjectId: r.record.subjectId,
subjectName: r.subjectName ?? "Unknown", subjectName: r.record.subjectId ? subjectNameById.get(r.record.subjectId) ?? "Unknown" : "Unknown",
examId: r.record.examId ?? null, examId: r.record.examId ?? null,
title: r.record.title, title: r.record.title,
score: toNumber(r.record.score), score: toNumber(r.record.score),
@@ -315,18 +331,19 @@ export async function getStudentGradeSummary(
return { return {
studentId, studentId,
studentName: student.name ?? "Unknown", studentName: studentName ?? "Unknown",
records: listItems, records: listItems,
averageScore: Math.round(avg * 100) / 100, averageScore: Math.round(avg * 100) / 100,
rank: 0, rank: 0,
} }
} }
export async function getClassRanking( export const getClassRanking = cache(
async (
classId: string, classId: string,
subjectId?: string, subjectId?: string,
examId?: string examId?: string
): Promise<ClassRankingItem[]> { ): Promise<ClassRankingItem[]> => {
const conditions = [eq(gradeRecords.classId, classId)] const conditions = [eq(gradeRecords.classId, classId)]
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId)) if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
if (examId) conditions.push(eq(gradeRecords.examId, examId)) if (examId) conditions.push(eq(gradeRecords.examId, examId))
@@ -334,44 +351,47 @@ export async function getClassRanking(
const rows = await db const rows = await db
.select({ .select({
studentId: gradeRecords.studentId, studentId: gradeRecords.studentId,
studentName: users.name,
avgScore: sql<number>`AVG(${gradeRecords.score})`, avgScore: sql<number>`AVG(${gradeRecords.score})`,
recordCount: count(gradeRecords.id), recordCount: count(gradeRecords.id),
}) })
.from(gradeRecords) .from(gradeRecords)
.leftJoin(users, eq(users.id, gradeRecords.studentId))
.where(and(...conditions)) .where(and(...conditions))
.groupBy(gradeRecords.studentId, users.name) .groupBy(gradeRecords.studentId)
.orderBy(desc(sql`AVG(${gradeRecords.score})`)) .orderBy(desc(sql`AVG(${gradeRecords.score})`))
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) => ({ return rows.map((r, idx) => ({
studentId: r.studentId, studentId: r.studentId,
studentName: r.studentName ?? "Unknown", studentName: studentNameMap.get(r.studentId)?.name ?? "Unknown",
averageScore: Math.round(toNumber(r.avgScore) * 100) / 100, averageScore: Math.round(toNumber(r.avgScore) * 100) / 100,
rank: idx + 1, rank: idx + 1,
recordCount: toNumber(r.recordCount), recordCount: toNumber(r.recordCount),
})) }))
} }
)
export async function getClassStudentsForEntry(classId: string): Promise< export async function getClassStudentsForEntry(classId: string): Promise<
Array<{ id: string; name: string; email: string }> Array<{ id: string; name: string; email: string }>
> { > {
const rows = await db const studentIds = await getActiveStudentIdsByClassId(classId)
.select({ if (studentIds.length === 0) return []
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))
return rows.map((r) => ({ const studentNameMap = await getUserNamesByIds(studentIds)
id: r.id,
name: r.name ?? "Unknown", return studentIds
email: r.email, .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( export async function getClassGradeStatsWithMeta(
@@ -379,18 +399,15 @@ export async function getClassGradeStatsWithMeta(
subjectId?: string, subjectId?: string,
examId?: string examId?: string
): Promise<ClassGradeStats | null> { ): Promise<ClassGradeStats | null> {
const [classRow] = await db const classExists = await getClassExists(classId)
.select({ id: classes.id, name: classes.name }) if (!classExists) return null
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
if (!classRow) return null
const className = await getClassNameById(classId)
const stats = await getClassGradeStats(classId, subjectId, examId) const stats = await getClassGradeStats(classId, subjectId, examId)
if (!stats) { if (!stats) {
return { return {
classId, classId,
className: classRow.name, className: className ?? "Unknown",
stats: { stats: {
average: 0, average: 0,
median: 0, median: 0,
@@ -405,15 +422,12 @@ export async function getClassGradeStatsWithMeta(
} }
} }
const [studentCountRow] = await db const activeStudentIds = await getActiveStudentIdsByClassId(classId)
.select({ c: count() })
.from(classEnrollments)
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
return { return {
classId, classId,
className: classRow.name, className: className ?? "Unknown",
stats, stats,
studentCount: toNumber(studentCountRow?.c ?? 0), studentCount: activeStudentIds.length,
} }
} }

View File

@@ -1,14 +1,8 @@
import "server-only" import "server-only"
import { eq } from "drizzle-orm" import { getClassNameById } from "@/modules/classes/data-access"
import { getSubjectOptions } from "@/modules/school/data-access"
import { db } from "@/shared/db" import { getUserNamesByIds } from "@/modules/users/data-access"
import {
classes,
gradeRecords,
subjects,
users,
} from "@/shared/db/schema"
import type { DataScope } from "@/shared/types/permissions" import type { DataScope } from "@/shared/types/permissions"
import { exportToExcel } from "@/shared/lib/excel" import { exportToExcel } from "@/shared/lib/excel"
@@ -113,45 +107,43 @@ export async function exportClassGradeReportToExcel(params: {
classId: string classId: string
scope: DataScope scope: DataScope
}): Promise<Buffer> { }): Promise<Buffer> {
const [classRow] = await db const className = (await getClassNameById(params.classId)) ?? "Unknown"
.select({ id: classes.id, name: classes.name })
.from(classes)
.where(eq(classes.id, params.classId))
.limit(1)
const className = classRow?.name ?? "Unknown"
// Get all subjects that have grade records for this class // Get all grade records for this class (already includes student/subject names via cross-module interfaces)
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
const allRecords = await getGradeRecords({ const allRecords = await getGradeRecords({
scope: params.scope, scope: params.scope,
classId: params.classId, 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[]>>() const scoreMap = new Map<string, Map<string, number[]>>()
for (const r of allRecords) { for (const r of allRecords) {
if (!scoreMap.has(r.studentId)) scoreMap.set(r.studentId, new Map()) 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) ?? [] const arr = subjMap.get(r.subjectId) ?? []
arr.push(r.score) arr.push(r.score)
subjMap.set(r.subjectId, arr) subjMap.set(r.subjectId, arr)
@@ -175,7 +167,7 @@ export async function exportClassGradeReportToExcel(params: {
const rowsData = studentRows.map((student) => { const rowsData = studentRows.map((student) => {
const subjMap = scoreMap.get(student.id) ?? new Map<string, number[]>() const subjMap = scoreMap.get(student.id) ?? new Map<string, number[]>()
const row: Record<string, unknown> = { const row: Record<string, unknown> = {
studentName: student.name ?? "Unknown", studentName: student.name,
} }
let total = 0 let total = 0
let count = 0 let count = 0

View File

@@ -244,7 +244,8 @@ export async function gradeHomeworkSubmissionAction(
try { try {
await requirePermission(Permissions.HOMEWORK_GRADE) 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({ const parsed = GradeHomeworkSchema.safeParse({
submissionId: formData.get("submissionId"), submissionId: formData.get("submissionId"),
answers: rawAnswers ? JSON.parse(rawAnswers) : [], answers: rawAnswers ? JSON.parse(rawAnswers) : [],

View 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
}
)

View File

@@ -5,16 +5,21 @@ import { and, count, eq } from "drizzle-orm"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { import {
classes,
classEnrollments,
classSubjectTeachers,
exams,
homeworkAnswers, homeworkAnswers,
homeworkAssignmentQuestions, homeworkAssignmentQuestions,
homeworkAssignmentTargets, homeworkAssignmentTargets,
homeworkAssignments, homeworkAssignments,
homeworkSubmissions, homeworkSubmissions,
} from "@/shared/db/schema" } 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" import type { DataScope } from "@/shared/types/permissions"
// ---- Types ---- // ---- Types ----
@@ -25,13 +30,7 @@ export type HomeworkExamQuestionData = {
order: number | null order: number | null
} }
export type HomeworkExamData = { export type HomeworkExamData = ExamWithQuestionsForHomework
id: string
title: string
subjectId: string | null
structure: unknown
questions: HomeworkExamQuestionData[]
}
export type HomeworkSubmissionPermissionData = { export type HomeworkSubmissionPermissionData = {
id: string id: string
@@ -63,85 +62,38 @@ export type CreateHomeworkAssignmentData = {
} }
// ---- Query helpers (for permission/validation in actions) ---- // ---- Query helpers (for permission/validation in actions) ----
// These delegate to cross-module data-access interfaces to avoid direct DB queries.
export const getClassTeacherById = async ( export const getClassTeacherById = async (
classId: string classId: string
): Promise<{ id: string; teacherId: string } | null> => { ): Promise<{ id: string; teacherId: string | null } | null> => {
const [row] = await db const teacherId = await getClassTeacherIdFromClass(classId)
.select({ id: classes.id, teacherId: classes.teacherId }) if (teacherId === null) return null
.from(classes) return { id: classId, teacherId }
.where(eq(classes.id, classId))
.limit(1)
return row ?? null
} }
export const getExamWithQuestionsForHomework = async ( export const getExamWithQuestionsForHomework = async (
examId: string examId: string
): Promise<HomeworkExamData | null> => { ): Promise<HomeworkExamData | null> => {
const exam = await db.query.exams.findFirst({ return await getExamWithQuestionsFromExams(examId)
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,
})),
}
} }
export const getTeacherAssignedSubjectIds = async ( export const getTeacherAssignedSubjectIds = async (
classId: string, classId: string,
teacherId: string teacherId: string
): Promise<string[]> => { ): Promise<string[]> => {
const rows = await db return await getTeacherSubjectIdsByClass(classId, teacherId)
.select({ subjectId: classSubjectTeachers.subjectId })
.from(classSubjectTeachers)
.where(
and(
eq(classSubjectTeachers.classId, classId),
eq(classSubjectTeachers.teacherId, teacherId)
)
)
return rows.map((r) => r.subjectId)
} }
export const getActiveClassStudentIdsForHomework = async ( export const getActiveClassStudentIdsForHomework = async (
classId: string, classId: string,
dataScope: DataScope, _dataScope: DataScope,
userId: string, _userId: string,
classTeacherId: string _classTeacherId: string | null
): Promise<string[]> => { ): Promise<string[]> => {
const classScope = // Permission/scope filtering is handled by requirePermission in actions.ts.
dataScope.type === "all" // This function returns active students for the class via the classes data-access interface.
? eq(classes.id, classId) return await getActiveStudentIdsByClassId(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)
} }
export const getHomeworkSubmissionForPermission = async ( export const getHomeworkSubmissionForPermission = async (
@@ -301,17 +253,19 @@ export const gradeHomeworkAnswers = async (
submissionId: string, submissionId: string,
answers: Array<{ id: string; score: number; feedback: string | null }> answers: Array<{ id: string; score: number; feedback: string | null }>
): Promise<void> => { ): Promise<void> => {
await db.transaction(async (tx) => {
let totalScore = 0 let totalScore = 0
for (const ans of answers) { for (const ans of answers) {
await db await tx
.update(homeworkAnswers) .update(homeworkAnswers)
.set({ score: ans.score, feedback: ans.feedback, updatedAt: new Date() }) .set({ score: ans.score, feedback: ans.feedback, updatedAt: new Date() })
.where(eq(homeworkAnswers.id, ans.id)) .where(eq(homeworkAnswers.id, ans.id))
totalScore += ans.score totalScore += ans.score
} }
await db await tx
.update(homeworkSubmissions) .update(homeworkSubmissions)
.set({ score: totalScore, status: "graded", updatedAt: new Date() }) .set({ score: totalScore, status: "graded", updatedAt: new Date() })
.where(eq(homeworkSubmissions.id, submissionId)) .where(eq(homeworkSubmissions.id, submissionId))
})
} }

View File

@@ -3,21 +3,17 @@ import "server-only"
import { cache } from "react" import { cache } from "react"
import { and, count, desc, eq, inArray, isNull, lte, or, sql } from "drizzle-orm" import { and, count, desc, eq, inArray, isNull, lte, or, sql } from "drizzle-orm"
import { auth } from "@/auth"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { import {
classEnrollments,
exams,
homeworkAnswers, homeworkAnswers,
homeworkAssignmentQuestions, homeworkAssignmentQuestions,
homeworkAssignmentTargets, homeworkAssignmentTargets,
homeworkAssignments, homeworkAssignments,
homeworkSubmissions, homeworkSubmissions,
roles,
subjects,
users,
usersToRoles,
} from "@/shared/db/schema" } 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 { import type {
HomeworkAssignmentListItem, HomeworkAssignmentListItem,
@@ -26,6 +22,7 @@ import type {
HomeworkAssignmentStatus, HomeworkAssignmentStatus,
HomeworkSubmissionDetails, HomeworkSubmissionDetails,
HomeworkSubmissionListItem, HomeworkSubmissionListItem,
HomeworkSubmissionStatus,
StudentHomeworkAssignmentListItem, StudentHomeworkAssignmentListItem,
StudentHomeworkProgressStatus, StudentHomeworkProgressStatus,
StudentHomeworkTakeData, 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 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 => { export const toQuestionContent = (v: unknown): HomeworkQuestionContent | null => {
if (!isRecord(v)) return null if (!isHomeworkQuestionContent(v)) return null
return v as HomeworkQuestionContent return v
} }
export const getAssignmentMaxScoreById = async (assignmentIds: string[]): Promise<Map<string, number>> => { 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?.creatorId) conditions.push(eq(homeworkAssignments.creatorId, params.creatorId))
if (params?.ids && params.ids.length > 0) conditions.push(inArray(homeworkAssignments.id, params.ids)) if (params?.ids && params.ids.length > 0) conditions.push(inArray(homeworkAssignments.id, params.ids))
if (params?.classId) { if (params?.classId) {
const classStudentIds = db const classStudentIds = await getStudentIdsByClassId(params.classId)
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(eq(classEnrollments.classId, params.classId))
const targetAssignmentIds = db const targetAssignmentIds = await db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId }) .selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets) .from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds)) .where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds)) conditions.push(inArray(homeworkAssignments.id, targetAssignmentIds.map((t) => t.assignmentId)))
} }
// Data scope filtering // 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) { if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
// Filter homework by assignments targeting students in teacher's classes // Filter homework by assignments targeting students in teacher's classes
const classStudentIds = db const classStudentIds = await getStudentIdsByClassIds(params.scope.classIds)
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(inArray(classEnrollments.classId, params.scope.classIds))
const targetAssignmentIds = db const targetAssignmentIds = await db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId }) .selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets) .from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds)) .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) { if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
// Homework links to exam via sourceExamId, exam has gradeId // Homework links to exam via sourceExamId, exam has gradeId
const gradeExamIds = db const gradeExamIds = await getExamIdsByGradeIds(params.scope.gradeIds)
.select({ id: exams.id })
.from(exams)
.where(inArray(exams.gradeId, params.scope.gradeIds))
conditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds)) conditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
} }
@@ -121,7 +124,7 @@ export const getHomeworkAssignments = cache(async (params?: { creatorId?: string
sourceExamId: a.sourceExamId, sourceExamId: a.sourceExamId,
sourceExamTitle: a.sourceExam.title, sourceExamTitle: a.sourceExam.title,
title: a.title, title: a.title,
status: (a.status as HomeworkAssignmentListItem["status"]) ?? "draft", status: toHomeworkAssignmentStatus(a.status),
availableAt: a.availableAt ? a.availableAt.toISOString() : null, availableAt: a.availableAt ? a.availableAt.toISOString() : null,
dueAt: a.dueAt ? a.dueAt.toISOString() : null, dueAt: a.dueAt ? a.dueAt.toISOString() : null,
allowLate: a.allowLate, allowLate: a.allowLate,
@@ -146,23 +149,17 @@ export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId:
// Already filtered by creatorId above // Already filtered by creatorId above
} }
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) { if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
const classStudentIds = db const classStudentIds = await getStudentIdsByClassIds(params.scope.classIds)
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(inArray(classEnrollments.classId, params.scope.classIds))
const targetAssignmentIds = db const targetAssignmentIds = await db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId }) .selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets) .from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds)) .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) { if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
const gradeExamIds = db const gradeExamIds = await getExamIdsByGradeIds(params.scope.gradeIds)
.select({ id: exams.id })
.from(exams)
.where(inArray(exams.gradeId, params.scope.gradeIds))
conditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds)) conditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
} }
@@ -223,7 +220,7 @@ export const getHomeworkAssignmentReviewList = cache(async (params: { creatorId:
const item: HomeworkAssignmentReviewListItem = { const item: HomeworkAssignmentReviewListItem = {
id: a.id, id: a.id,
title: a.title, title: a.title,
status: (a.status as HomeworkAssignmentReviewListItem["status"]) ?? "draft", status: toHomeworkAssignmentStatus(a.status),
sourceExamTitle: a.sourceExam.title, sourceExamTitle: a.sourceExam.title,
dueAt: a.dueAt ? a.dueAt.toISOString() : null, dueAt: a.dueAt ? a.dueAt.toISOString() : null,
targetCount: targetCountByAssignmentId.get(a.id) ?? 0, targetCount: targetCountByAssignmentId.get(a.id) ?? 0,
@@ -239,18 +236,15 @@ export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: str
const conditions = [] const conditions = []
if (params?.assignmentId) conditions.push(eq(homeworkSubmissions.assignmentId, params.assignmentId)) if (params?.assignmentId) conditions.push(eq(homeworkSubmissions.assignmentId, params.assignmentId))
if (params?.classId) { if (params?.classId) {
const classStudentIds = db const classStudentIds = await getStudentIdsByClassId(params.classId)
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(eq(classEnrollments.classId, params.classId))
const targetAssignmentIds = db const targetAssignmentIds = await db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId }) .selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets) .from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds)) .where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
conditions.push(inArray(homeworkSubmissions.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) { if (params?.creatorId) {
const creatorAssignmentIds = db const creatorAssignmentIds = db
@@ -272,18 +266,12 @@ export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: str
conditions.push(inArray(homeworkSubmissions.assignmentId, creatorAssignmentIds)) conditions.push(inArray(homeworkSubmissions.assignmentId, creatorAssignmentIds))
} }
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) { if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
const classStudentIds = db const classStudentIds = await getStudentIdsByClassIds(params.scope.classIds)
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(inArray(classEnrollments.classId, params.scope.classIds))
conditions.push(inArray(homeworkSubmissions.studentId, classStudentIds)) conditions.push(inArray(homeworkSubmissions.studentId, classStudentIds))
} }
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) { if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
const gradeExamIds = db const gradeExamIds = await getExamIdsByGradeIds(params.scope.gradeIds)
.select({ id: exams.id })
.from(exams)
.where(inArray(exams.gradeId, params.scope.gradeIds))
const gradeAssignmentIds = db const gradeAssignmentIds = db
.select({ assignmentId: homeworkAssignments.id }) .select({ assignmentId: homeworkAssignments.id })
@@ -311,7 +299,7 @@ export const getHomeworkSubmissions = cache(async (params?: { assignmentId?: str
studentName: s.student.name || "Unknown", studentName: s.student.name || "Unknown",
submittedAt: s.submittedAt ? s.submittedAt.toISOString() : null, submittedAt: s.submittedAt ? s.submittedAt.toISOString() : null,
score: s.score ?? null, score: s.score ?? null,
status: (s.status as HomeworkSubmissionListItem["status"]) ?? "started", status: toHomeworkSubmissionStatus(s.status),
isLate: s.isLate, isLate: s.isLate,
} }
return item return item
@@ -334,21 +322,13 @@ export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataSc
return null return null
} }
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) { if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
const gradeExamIds = await db const examIds = await getExamIdsByGradeIds(scope.gradeIds)
.select({ id: exams.id })
.from(exams)
.where(inArray(exams.gradeId, scope.gradeIds))
const examIds = gradeExamIds.map(e => e.id)
if (!examIds.includes(assignment.sourceExamId)) { if (!examIds.includes(assignment.sourceExamId)) {
return null return null
} }
} }
if (scope.type === "class_taught" && scope.classIds.length > 0) { if (scope.type === "class_taught" && scope.classIds.length > 0) {
const classStudentIds = await db const studentIds = await getStudentIdsByClassIds(scope.classIds)
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(inArray(classEnrollments.classId, scope.classIds))
const studentIds = classStudentIds.map(s => s.studentId)
if (studentIds.length > 0) { if (studentIds.length > 0) {
const target = await db.query.homeworkAssignmentTargets.findFirst({ const target = await db.query.homeworkAssignmentTargets.findFirst({
where: and( where: and(
@@ -389,7 +369,7 @@ export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataSc
id: assignment.id, id: assignment.id,
title: assignment.title, title: assignment.title,
description: assignment.description, description: assignment.description,
status: (assignment.status as HomeworkAssignmentStatus) ?? "draft", status: toHomeworkAssignmentStatus(assignment.status),
sourceExamId: assignment.sourceExamId, sourceExamId: assignment.sourceExamId,
sourceExamTitle: assignment.sourceExam.title, sourceExamTitle: assignment.sourceExam.title,
structure: assignment.structure as unknown, structure: assignment.structure as unknown,
@@ -464,7 +444,7 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
assignmentTitle: submission.assignment.title, assignmentTitle: submission.assignment.title,
studentName: submission.student.name || "Unknown", studentName: submission.student.name || "Unknown",
submittedAt: submission.submittedAt ? submission.submittedAt.toISOString() : null, submittedAt: submission.submittedAt ? submission.submittedAt.toISOString() : null,
status: submission.status as HomeworkSubmissionDetails["status"], status: toHomeworkSubmissionStatus(submission.status),
totalScore: submission.score, totalScore: submission.score,
answers: answersWithDetails, answers: answersWithDetails,
prevSubmissionId, prevSubmissionId,
@@ -472,22 +452,9 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
} }
}) })
export const getDemoStudentUser = cache(async (): Promise<{ id: string; name: string } | null> => { // Re-export getDemoStudentUser from users module for backward compatibility.
const session = await auth() // New code should import getCurrentStudentUser from "@/modules/users/data-access" instead.
const userId = String(session?.user?.id ?? "").trim() export { getCurrentStudentUser as getDemoStudentUser } from "@/modules/users/data-access"
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" }
})
const toStudentProgressStatus = (v: string | null | undefined): StudentHomeworkProgressStatus => { const toStudentProgressStatus = (v: string | null | undefined): StudentHomeworkProgressStatus => {
if (v === "started") return "in_progress" if (v === "started") return "in_progress"
@@ -508,15 +475,13 @@ export const getStudentHomeworkAssignments = cache(async (studentId: string): Pr
.select({ .select({
id: homeworkAssignments.id, id: homeworkAssignments.id,
title: homeworkAssignments.title, title: homeworkAssignments.title,
sourceExamId: homeworkAssignments.sourceExamId,
dueAt: homeworkAssignments.dueAt, dueAt: homeworkAssignments.dueAt,
availableAt: homeworkAssignments.availableAt, availableAt: homeworkAssignments.availableAt,
maxAttempts: homeworkAssignments.maxAttempts, maxAttempts: homeworkAssignments.maxAttempts,
createdAt: homeworkAssignments.createdAt, createdAt: homeworkAssignments.createdAt,
subjectName: subjects.name,
}) })
.from(homeworkAssignments) .from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
.where( .where(
and( and(
eq(homeworkAssignments.status, "published"), eq(homeworkAssignments.status, "published"),
@@ -528,6 +493,15 @@ export const getStudentHomeworkAssignments = cache(async (studentId: string): Pr
if (assignments.length === 0) return [] 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 assignmentIds = assignments.map((a) => a.id)
const submissions = await db.query.homeworkSubmissions.findMany({ const submissions = await db.query.homeworkSubmissions.findMany({
where: and(eq(homeworkSubmissions.studentId, studentId), inArray(homeworkSubmissions.assignmentId, assignmentIds)), 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) => { return assignments.map((a) => {
const latest = latestSubmittedByAssignmentId.get(a.id) ?? latestByAssignmentId.get(a.id) ?? null const latest = latestSubmittedByAssignmentId.get(a.id) ?? latestByAssignmentId.get(a.id) ?? null
const attemptsUsed = attemptsByAssignmentId.get(a.id) ?? 0 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 = { const item: StudentHomeworkAssignmentListItem = {
id: a.id, id: a.id,
title: a.title, title: a.title,
subjectName: a.subjectName ?? null, subjectName: subjectName ?? null,
dueAt: a.dueAt ? a.dueAt.toISOString() : null, dueAt: a.dueAt ? a.dueAt.toISOString() : null,
availableAt: a.availableAt ? a.availableAt.toISOString() : null, availableAt: a.availableAt ? a.availableAt.toISOString() : null,
maxAttempts: a.maxAttempts, maxAttempts: a.maxAttempts,
@@ -642,7 +618,7 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
submission: latestSubmission submission: latestSubmission
? { ? {
id: latestSubmission.id, id: latestSubmission.id,
status: (latestSubmission.status as NonNullable<StudentHomeworkTakeData["submission"]>["status"]) ?? "started", status: toHomeworkSubmissionStatus(latestSubmission.status),
attemptNo: latestSubmission.attemptNo, attemptNo: latestSubmission.attemptNo,
submittedAt: latestSubmission.submittedAt ? latestSubmission.submittedAt.toISOString() : null, submittedAt: latestSubmission.submittedAt ? latestSubmission.submittedAt.toISOString() : null,
score: latestSubmission.score ?? null, score: latestSubmission.score ?? null,

View File

@@ -5,15 +5,18 @@ import { and, count, desc, eq, inArray, or, sql } from "drizzle-orm"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { import {
classEnrollments,
classes,
exams,
homeworkAssignmentQuestions, homeworkAssignmentQuestions,
homeworkAssignmentTargets, homeworkAssignmentTargets,
homeworkAssignments, homeworkAssignments,
homeworkSubmissions, homeworkSubmissions,
users,
} from "@/shared/db/schema" } 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 { import type {
HomeworkAssignmentAnalytics, HomeworkAssignmentAnalytics,
@@ -27,6 +30,12 @@ import type {
import type { DataScope } from "@/shared/types/permissions" import type { DataScope } from "@/shared/types/permissions"
import { getAssignmentMaxScoreById, isRecord, toQuestionContent } from "./data-access" 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. * Get grade trend data for a teacher's recent assignments.
* Used by the teacher dashboard to visualize class performance over time. * Used by the teacher dashboard to visualize class performance over time.
@@ -217,7 +226,7 @@ export const getHomeworkAssignmentAnalytics = cache(
id: assignment.id, id: assignment.id,
title: assignment.title, title: assignment.title,
description: assignment.description, description: assignment.description,
status: (assignment.status as HomeworkAssignmentStatus) ?? "draft", status: toHomeworkAssignmentStatus(assignment.status),
sourceExamId: assignment.sourceExamId, sourceExamId: assignment.sourceExamId,
sourceExamTitle: assignment.sourceExam.title, sourceExamTitle: assignment.sourceExam.title,
structure: assignment.structure as unknown, structure: assignment.structure as unknown,
@@ -310,19 +319,10 @@ export const getStudentDashboardGrades = cache(async (studentId: string): Promis
const trend = trendSubmissions.map(toAnalytics) const trend = trendSubmissions.map(toAnalytics)
const recent = recentSubmissions.map(toAnalytics).slice(0, 5) const recent = recentSubmissions.map(toAnalytics).slice(0, 5)
const enrollment = await db.query.classEnrollments.findFirst({ const classId = await getStudentActiveClassId(id)
where: and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")), if (!classId) return { trend, recent, ranking: null }
orderBy: (e, { asc }) => [asc(e.createdAt)],
})
if (!enrollment) return { trend, recent, ranking: null } const classStudentIds = await getActiveStudentIdsByClassId(classId)
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 classSize = classStudentIds.length const classSize = classStudentIds.length
if (classSize === 0) return { trend, recent, ranking: null } if (classSize === 0) return { trend, recent, ranking: null }
@@ -363,13 +363,8 @@ export const getStudentDashboardGrades = cache(async (studentId: string): Promis
}) })
} }
const classUsers = await db const userNamesMap = await getUserNamesByIds(classStudentIds)
.select({ id: users.id, name: users.name }) const myName = userNamesMap.get(id)?.name ?? "Student"
.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 ranked = classStudentIds const ranked = classStudentIds
.map((studentId) => { .map((studentId) => {
@@ -422,10 +417,7 @@ export const getHomeworkDashboardStats = cache(async (scope?: DataScope): Promis
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, ownedAssignmentIds)) submissionConditions.push(inArray(homeworkSubmissions.assignmentId, ownedAssignmentIds))
} }
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) { if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
const gradeExamIds = db const gradeExamIds = await getExamIdsByGradeIds(scope.gradeIds)
.select({ id: exams.id })
.from(exams)
.where(inArray(exams.gradeId, scope.gradeIds))
homeworkConditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds)) homeworkConditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
const gradeAssignmentIds = db const gradeAssignmentIds = db
.select({ id: homeworkAssignments.id }) .select({ id: homeworkAssignments.id })
@@ -434,16 +426,9 @@ export const getHomeworkDashboardStats = cache(async (scope?: DataScope): Promis
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, gradeAssignmentIds)) submissionConditions.push(inArray(homeworkSubmissions.assignmentId, gradeAssignmentIds))
} }
if (scope.type === "class_taught" && scope.classIds.length > 0) { if (scope.type === "class_taught" && scope.classIds.length > 0) {
const teacherGradeIds = await db const gradeIds = await getGradeIdsByClassIds(scope.classIds)
.selectDistinct({ gradeId: classes.gradeId })
.from(classes)
.where(inArray(classes.id, scope.classIds))
const gradeIds = teacherGradeIds.map((g) => g.gradeId).filter(Boolean) as string[]
if (gradeIds.length > 0) { if (gradeIds.length > 0) {
const gradeExamIds = db const gradeExamIds = await getExamIdsByGradeIds(gradeIds)
.select({ id: exams.id })
.from(exams)
.where(inArray(exams.gradeId, gradeIds))
homeworkConditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds)) homeworkConditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
const gradeAssignmentIds = db const gradeAssignmentIds = db
.select({ id: homeworkAssignments.id }) .select({ id: homeworkAssignments.id })

View File

@@ -1,12 +1,16 @@
"use server" "use server"
import { revalidatePath } from "next/cache" 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 { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state" import type { ActionState } from "@/shared/types/action-state"
import { sendNotification } from "@/modules/notifications/dispatcher" import { sendNotification } from "@/modules/notifications/dispatcher"
import { SendMessageSchema } from "./schema" import {
SendMessageSchema,
MessageIdSchema,
UpdateNotificationPreferencesSchema,
} from "./schema"
import { import {
getMessages, getMessages,
getMessageById, getMessageById,
@@ -87,9 +91,15 @@ export async function sendMessageAction(
export async function markMessageAsReadAction(messageId: string): Promise<ActionState<string>> { export async function markMessageAsReadAction(messageId: string): Promise<ActionState<string>> {
try { try {
const ctx = await requirePermission(Permissions.MESSAGE_READ) 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")
revalidatePath(`/messages/${messageId}`) revalidatePath(`/messages/${parsed.data.messageId}`)
return { success: true, message: "Marked as read" } return { success: true, message: "Marked as read" }
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message } 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>> { export async function deleteMessageAction(messageId: string): Promise<ActionState<string>> {
try { try {
const ctx = await requirePermission(Permissions.MESSAGE_DELETE) 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")
revalidatePath(`/messages/${messageId}`) revalidatePath(`/messages/${parsed.data.messageId}`)
return { success: true, message: "Message deleted" } return { success: true, message: "Message deleted" }
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message } 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>> { export async function getMessageDetailAction(messageId: string): Promise<ActionState<Message>> {
try { try {
const ctx = await requirePermission(Permissions.MESSAGE_READ) 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" } if (!message) return { success: false, message: "Message not found" }
// Auto-mark as read when viewed by receiver // Auto-mark as read when viewed by receiver
if (!message.isRead && message.receiverId === ctx.userId) { if (!message.isRead && message.receiverId === ctx.userId) {
await markMessageAsRead(messageId, ctx.userId) await markMessageAsRead(validMessageId, ctx.userId)
revalidatePath("/messages") revalidatePath("/messages")
} }
return { success: true, data: message } return { success: true, data: message }
@@ -160,7 +183,7 @@ export async function getNotificationsAction(
params?: { page?: number; pageSize?: number; unreadOnly?: boolean } params?: { page?: number; pageSize?: number; unreadOnly?: boolean }
): Promise<ActionState<{ items: Notification[]; total: number; page: number; pageSize: number; totalPages: number }>> { ): Promise<ActionState<{ items: Notification[]; total: number; page: number; pageSize: number; totalPages: number }>> {
try { try {
const ctx = await requireAuth() const ctx = await requirePermission(Permissions.MESSAGE_READ)
const result = await getNotifications(ctx.userId, params) const result = await getNotifications(ctx.userId, params)
return { success: true, data: result } return { success: true, data: result }
} catch (e) { } catch (e) {
@@ -174,7 +197,7 @@ export async function markNotificationAsReadAction(
notificationId: string notificationId: string
): Promise<ActionState<string>> { ): Promise<ActionState<string>> {
try { try {
const ctx = await requireAuth() const ctx = await requirePermission(Permissions.MESSAGE_READ)
await markNotificationAsRead(notificationId, ctx.userId) await markNotificationAsRead(notificationId, ctx.userId)
revalidatePath("/messages") revalidatePath("/messages")
return { success: true, message: "Notification marked as read" } return { success: true, message: "Notification marked as read" }
@@ -187,7 +210,7 @@ export async function markNotificationAsReadAction(
export async function markAllNotificationsAsReadAction(): Promise<ActionState<string>> { export async function markAllNotificationsAsReadAction(): Promise<ActionState<string>> {
try { try {
const ctx = await requireAuth() const ctx = await requirePermission(Permissions.MESSAGE_READ)
await markAllNotificationsAsRead(ctx.userId) await markAllNotificationsAsRead(ctx.userId)
revalidatePath("/messages") revalidatePath("/messages")
return { success: true, message: "All notifications marked as read" } 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>> { export async function getNotificationPreferencesAction(): Promise<ActionState<NotificationPreferences>> {
try { try {
const ctx = await requireAuth() const ctx = await requirePermission(Permissions.MESSAGE_READ)
const prefs = await getNotificationPreferences(ctx.userId) const prefs = await getNotificationPreferences(ctx.userId)
return { success: true, data: prefs } return { success: true, data: prefs }
} catch (e) { } catch (e) {
@@ -215,12 +238,12 @@ export async function updateNotificationPreferencesAction(
formData: FormData formData: FormData
): Promise<ActionState<NotificationPreferences>> { ): Promise<ActionState<NotificationPreferences>> {
try { try {
const ctx = await requireAuth() const ctx = await requirePermission(Permissions.MESSAGE_READ)
// 从 FormData 中解析布尔值checkbox 提交 "on" 或不提交) // 从 FormData 中解析布尔值checkbox 提交 "on" 或不提交)
const parseBool = (key: string): boolean => formData.get(key) === "on" const parseBool = (key: string): boolean => formData.get(key) === "on"
const input: UpdateNotificationPreferencesInput = { const parsed = UpdateNotificationPreferencesSchema.safeParse({
emailEnabled: parseBool("emailEnabled"), emailEnabled: parseBool("emailEnabled"),
smsEnabled: parseBool("smsEnabled"), smsEnabled: parseBool("smsEnabled"),
pushEnabled: parseBool("pushEnabled"), pushEnabled: parseBool("pushEnabled"),
@@ -229,8 +252,14 @@ export async function updateNotificationPreferencesAction(
announcementNotifications: parseBool("announcementNotifications"), announcementNotifications: parseBool("announcementNotifications"),
messageNotifications: parseBool("messageNotifications"), messageNotifications: parseBool("messageNotifications"),
attendanceNotifications: parseBool("attendanceNotifications"), 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) const updated = await upsertNotificationPreferences(ctx.userId, input)
if (!updated) { if (!updated) {
return { success: false, message: "Failed to update notification preferences" } return { success: false, message: "Failed to update notification preferences" }

View File

@@ -1,5 +1,20 @@
import "server-only" import "server-only"
/**
* 私信数据访问层
*
* 职责:
* - getMessages / getMessageById / getMessageThread: 私信查询
* - createMessage / markMessageAsRead / deleteMessage: 私信 CRUD
* - getUnreadMessageCount: 未读私信计数
* - getRecipients: 获取收件人列表(按 DataScope 过滤)
*
* 注意: 通知相关函数createNotification / getNotifications /
* markNotificationAsRead / markAllNotificationsAsRead / getUnreadNotificationCount
* 已迁移到 notifications/data-access.tsP0-4 / P1-5 修复)。
* 本文件通过 re-export 保持向后兼容,现有调用方无需修改 import 路径。
*/
import { cache } from "react" import { cache } from "react"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import { and, count, desc, eq, inArray, or } from "drizzle-orm" 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 { db } from "@/shared/db"
import { import {
messages, messages,
messageNotifications,
users, users,
classEnrollments, classEnrollments,
classes, classes,
@@ -15,18 +29,16 @@ import {
import type { DataScope } from "@/shared/types/permissions" import type { DataScope } from "@/shared/types/permissions"
import type { import type {
Message, Message,
Notification,
NotificationType,
GetMessagesParams, GetMessagesParams,
GetNotificationsParams,
CreateMessageInput, CreateMessageInput,
CreateNotificationInput,
PaginatedResult, PaginatedResult,
RecipientOption, RecipientOption,
} from "./types" } from "./types"
const toIso = (d: Date | null | undefined): string | null => (d ? d.toISOString() : null) const toIso = (d: Date | null | undefined): string | null => (d ? d.toISOString() : null)
const toIsoRequired = (d: Date): string => d.toISOString()
interface MessageRow { interface MessageRow {
id: string id: string
senderId: string senderId: string
@@ -39,17 +51,6 @@ interface MessageRow {
createdAt: Date 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>> { async function resolveUserNames(userIds: string[]): Promise<Map<string, string>> {
const uniqueIds = [...new Set(userIds)].filter(Boolean) const uniqueIds = [...new Set(userIds)].filter(Boolean)
if (uniqueIds.length === 0) return new Map() if (uniqueIds.length === 0) return new Map()
@@ -71,18 +72,7 @@ const mapMessage = (r: MessageRow, nameMap: Map<string, string>): Message => ({
isRead: r.isRead, isRead: r.isRead,
readAt: toIso(r.readAt), readAt: toIso(r.readAt),
parentMessageId: r.parentMessageId, parentMessageId: r.parentMessageId,
createdAt: toIso(r.createdAt) as string, createdAt: toIsoRequired(r.createdAt),
})
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,
}) })
export const getMessages = cache( export const getMessages = cache(
@@ -94,7 +84,10 @@ export const getMessages = cache(
const conds = [] const conds = []
if (params.type === "inbox") conds.push(eq(messages.receiverId, params.userId)) if (params.type === "inbox") conds.push(eq(messages.receiverId, params.userId))
else if (params.type === "sent") conds.push(eq(messages.senderId, 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 where = and(...conds)
const [rows, [totalRow]] = await Promise.all([ const [rows, [totalRow]] = await Promise.all([
@@ -114,7 +107,7 @@ export const getMessageById = cache(
const [row] = await db const [row] = await db
.select() .select()
.from(messages) .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) .limit(1)
if (!row) return null if (!row) return null
const nameMap = await resolveUserNames([row.senderId, row.receiverId]) 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> { export async function deleteMessage(id: string, userId: string): Promise<void> {
await db await db
.delete(messages) .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> => { 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) 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( export const getRecipients = cache(
async (userId: string, scope: DataScope): Promise<RecipientOption[]> => { async (userId: string, scope: DataScope): Promise<RecipientOption[]> => {
if (scope.type === "all") { if (scope.type === "all") {
@@ -250,3 +190,15 @@ export const getRecipients = cache(
return [] return []
} }
) )
// ---------------------------------------------------------------------------
// 向后兼容 re-export通知 CRUD 已迁移到 notifications/data-access.ts
// ---------------------------------------------------------------------------
export {
createNotification,
getNotifications,
markNotificationAsRead,
markAllNotificationsAsRead,
getUnreadNotificationCount,
} from "@/modules/notifications/data-access"

View File

@@ -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.tsP0-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) { export {
return mapRow(existing) getNotificationPreferences,
} upsertNotificationPreferences,
} from "@/modules/notifications/preferences"
// 不存在则创建默认记录
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
}
}

View File

@@ -16,3 +16,26 @@ export const SendMessageSchema = z
})) }))
export type SendMessageInput = z.infer<typeof SendMessageSchema> 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
>

View File

@@ -1,3 +1,12 @@
/**
* 私信模块类型定义
*
* 注意: 通知相关类型NotificationType, Notification, NotificationPreferences,
* UpdateNotificationPreferencesInput, CreateNotificationInput, GetNotificationsParams,
* PaginatedResult已迁移到 notifications/types.tsP0-4 / P1-5 修复)。
* 本文件通过 re-export 保持向后兼容,现有调用方无需修改 import 路径。
*/
export type MessageType = "inbox" | "sent" | "all" export type MessageType = "inbox" | "sent" | "all"
export interface Message { export interface Message {
@@ -20,29 +29,6 @@ export interface MessageThread {
messages: Message[] 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 { export interface GetMessagesParams {
userId: string userId: string
type: MessageType type: MessageType
@@ -50,12 +36,6 @@ export interface GetMessagesParams {
pageSize?: number pageSize?: number
} }
export interface GetNotificationsParams {
page?: number
pageSize?: number
unreadOnly?: boolean
}
export interface CreateMessageInput { export interface CreateMessageInput {
senderId: string senderId: string
receiverId: string receiverId: string
@@ -64,14 +44,6 @@ export interface CreateMessageInput {
parentMessageId?: string | null parentMessageId?: string | null
} }
export interface CreateNotificationInput {
userId: string
type: NotificationType
title: string
content?: string | null
link?: string | null
}
export interface RecipientOption { export interface RecipientOption {
id: string id: string
name: string name: string
@@ -79,30 +51,17 @@ export interface RecipientOption {
role?: string role?: string
} }
// 通知偏好设置 // ---------------------------------------------------------------------------
export interface NotificationPreferences { // 向后兼容 re-export通知相关类型已迁移到 notifications/types.ts
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 type {
export interface UpdateNotificationPreferencesInput { NotificationType,
emailEnabled?: boolean Notification,
smsEnabled?: boolean NotificationListItem,
pushEnabled?: boolean GetNotificationsParams,
homeworkNotifications?: boolean CreateNotificationInput,
gradeNotifications?: boolean PaginatedResult,
announcementNotifications?: boolean NotificationPreferences,
messageNotifications?: boolean UpdateNotificationPreferencesInput,
attendanceNotifications?: boolean } from "@/modules/notifications/types"
}

View File

@@ -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 { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions" import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state" import type { ActionState } from "@/shared/types/action-state"
import { getClassExists, getStudentIdsByClassId } from "@/modules/classes/data-access"
import { sendNotification, sendBatchNotifications } from "./dispatcher" import { sendNotification, sendBatchNotifications } from "./dispatcher"
import type { NotificationPayload, ChannelSendResult } from "./types" import type { NotificationPayload, ChannelSendResult } from "./types"
@@ -79,36 +77,29 @@ export async function sendClassNotificationAction(
} }
} }
// 查询班级所有学生 // 校验班级是否存在
const [classRow] = await db const classExists = await getClassExists(classId)
.select({ id: classes.id }) if (!classExists) {
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
if (!classRow) {
return { success: false, message: "Class not found" } return { success: false, message: "Class not found" }
} }
const enrollments = await db // 查询班级所有学生
.select({ studentId: classEnrollments.studentId }) const studentIds = await getStudentIdsByClassId(classId)
.from(classEnrollments)
.where(eq(classEnrollments.classId, classId))
if (enrollments.length === 0) { if (studentIds.length === 0) {
return { success: true, message: "No students in this class", data: [] } return { success: true, message: "No students in this class", data: [] }
} }
// 构造每个学生的通知负载 // 构造每个学生的通知负载
const payloads: NotificationPayload[] = enrollments.map((e) => ({ const payloads: NotificationPayload[] = studentIds.map((studentId) => ({
...payload, ...payload,
userId: e.studentId, userId: studentId,
})) }))
const results = await sendBatchNotifications(payloads) const results = await sendBatchNotifications(payloads)
return { return {
success: true, success: true,
message: `Notification sent to ${enrollments.length} students`, message: `Notification sent to ${studentIds.length} students`,
data: results, data: results,
} }
} catch (e) { } catch (e) {

View File

@@ -26,7 +26,13 @@ import type { NotificationChannelSender, ChannelRecipient } from "./types"
const channel: NotificationChannel = "email" const channel: NotificationChannel = "email"
/** 从环境变量读取邮件配置 */ /** 从环境变量读取邮件配置 */
function getEmailConfig() { function getEmailConfig(): {
host: string | undefined
port: number
user: string | undefined
pass: string | undefined
from: string
} {
return { return {
host: process.env.EMAIL_HOST, host: process.env.EMAIL_HOST,
port: Number(process.env.EMAIL_PORT ?? "587"), port: Number(process.env.EMAIL_PORT ?? "587"),

View File

@@ -3,22 +3,22 @@ import "server-only"
/** /**
* 站内消息渠道 * 站内消息渠道
* *
* 封装现有 messaging 模块的 data-access.createNotification * 封装 notifications 模块的 data-access.createNotification
* 将其适配为统一的 NotificationChannelSender 接口。 * 将其适配为统一的 NotificationChannelSender 接口。
* *
* 这是默认渠道,总是启用。所有通知都会写入 message_notifications 表, * 这是默认渠道,总是启用。所有通知都会写入 message_notifications 表,
* 用户可在站内通知中心查看。 * 用户可在站内通知中心查看。
* *
* 注意: messaging.NotificationType 为 "message" | "announcement" | "homework" | "grade" * 注意: NotificationType 为 "message" | "announcement" | "homework" | "grade"
* 而本模块 NotificationPayload.type 为 "info" | "warning" | "error" | "success"。 * 而本模块 NotificationPayload.type 为 "info" | "warning" | "error" | "success"。
* 此处将 payload.type 作为字符串写入 DBDB 列为 varchar(128),支持任意值 * 通过 mapPayloadTypeToNotificationType 函数进行语义映射P0-11 修复
* 不破坏现有 messaging 模块的类型约束 * 不再使用非法的 as 断言
* *
* 使用动态 import 打破 notifications -> messaging 的静态反向依赖。 * P0-4 / P1-5 修复后createNotification 已迁移到 notifications/data-access.ts
* 运行时调用链: messaging -> dispatcher -> in-app channel -> messaging.createNotification (存储) * 不再需要动态 import messaging 模块,消除了 notifications -> messaging 的反向依赖。
* 这是可接受的运行时调用链,但模块级静态依赖必须单向。
*/ */
import { createNotification } from "../data-access"
import type { import type {
NotificationPayload, NotificationPayload,
ChannelSendResult, ChannelSendResult,
@@ -28,7 +28,34 @@ import type { NotificationChannelSender, ChannelRecipient } from "./types"
const channel: NotificationChannel = "in_app" 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 { class InAppChannelSender implements NotificationChannelSender {
readonly channel = channel readonly channel = channel
@@ -46,12 +73,10 @@ class InAppChannelSender implements NotificationChannelSender {
sentAt: new Date(), 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({ const id = await createNotification({
userId: payload.userId, userId: payload.userId,
// DB 列为 varchar(128),支持任意字符串;保留 payload.type 语义 // Map payload.type to NotificationType via type-safe mapping (P0-11)
type: payload.type as "message" | "announcement" | "homework" | "grade", type: mapPayloadTypeToNotificationType(payload.type),
title: payload.title, title: payload.title,
content: payload.content, content: payload.content,
link: payload.actionUrl ?? null, link: payload.actionUrl ?? null,

View File

@@ -26,10 +26,22 @@ import type { NotificationChannelSender, ChannelRecipient } from "./types"
const channel: NotificationChannel = "sms" const channel: NotificationChannel = "sms"
type SmsProvider = "aliyun" | "tencent" | "mock"
const isSmsProvider = (v: unknown): v is SmsProvider =>
v === "aliyun" || v === "tencent" || v === "mock"
/** 从环境变量读取 SMS 配置 */ /** 从环境变量读取 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 { 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, accessKeyId: process.env.SMS_ACCESS_KEY_ID,
accessKeySecret: process.env.SMS_ACCESS_KEY_SECRET, accessKeySecret: process.env.SMS_ACCESS_KEY_SECRET,
signName: process.env.SMS_SIGN_NAME, signName: process.env.SMS_SIGN_NAME,

View File

@@ -37,7 +37,11 @@ interface TokenCache {
let tokenCache: TokenCache | null = null let tokenCache: TokenCache | null = null
/** 从环境变量读取微信配置 */ /** 从环境变量读取微信配置 */
function getWechatConfig() { function getWechatConfig(): {
appId: string | undefined
appSecret: string | undefined
templateId: string | undefined
} {
return { return {
appId: process.env.WECHAT_APP_ID, appId: process.env.WECHAT_APP_ID,
appSecret: process.env.WECHAT_APP_SECRET, appSecret: process.env.WECHAT_APP_SECRET,

View File

@@ -4,34 +4,126 @@ import "server-only"
* 通知数据访问层 * 通知数据访问层
* *
* 职责: * 职责:
* - getUserNotificationPreferences: 获取用户通知偏好(复用 messaging 模块 * - createNotification: 创建站内通知记录message_notifications 表
* - getNotifications / markNotificationAsRead / markAllNotificationsAsRead / getUnreadNotificationCount: 站内通知 CRUD
* - getUserContactInfo: 获取用户联系方式(手机/邮箱,用于渠道发送) * - getUserContactInfo: 获取用户联系方式(手机/邮箱,用于渠道发送)
* - logNotificationSend: 记录发送日志(当前项目无 notification_logs 表,使用 console 输出) * - logNotificationSend: 记录发送日志(当前项目无 notification_logs 表,使用 console 输出)
* *
* 表所有权:
* - message_notifications由 notifications 模块统一管理P0-4 / P1-5 修复后从 messaging 迁移)
* - notification_preferences由 notifications/preferences.ts 管理)
*
* 注意: users 表当前无 wechatOpenId 字段wechatOpenId 暂返回 undefined。 * 注意: users 表当前无 wechatOpenId 字段wechatOpenId 暂返回 undefined。
* 未来扩展 users 表增加 wechat_open_id 列后,此处补充查询即可。 * 未来扩展 users 表增加 wechat_open_id 列后,此处补充查询即可。
*/ */
import { cache } from "react" 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 { db } from "@/shared/db"
import { users } from "@/shared/db/schema" import { messageNotifications, users } from "@/shared/db/schema"
import { getNotificationPreferences } from "@/modules/messaging/notification-preferences"
import type { NotificationPreferences } from "@/modules/messaging/types"
import type { ChannelRecipient } from "./channels/types" import type { ChannelRecipient } from "./channels/types"
import type { ChannelSendResult } from "./types" import type {
ChannelSendResult,
CreateNotificationInput,
GetNotificationsParams,
Notification,
NotificationType,
PaginatedResult,
} from "./types"
/** const toIsoRequired = (d: Date): string => d.toISOString()
* 获取用户通知偏好(复用 messaging 模块的 cache 包装函数)。
* 若用户无记录messaging 模块会自动创建默认记录。 const isNotificationType = (v: unknown): v is NotificationType =>
*/ v === "message" || v === "announcement" || v === "homework" || v === "grade"
export async function getUserNotificationPreferences(
const toNotificationType = (v: string): NotificationType =>
isNotificationType(v) ? v : "message"
interface NotificationRow {
id: string
userId: string userId: string
): Promise<NotificationPreferences> { type: string
return getNotificationPreferences(userId) 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),
})
// ---------------------------------------------------------------------------
// 站内通知 CRUDmessage_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。 * wechatOpenId 暂不支持users 表无此字段),返回 undefined。
@@ -62,6 +154,10 @@ export const getUserContactInfo = cache(
} }
) )
// ---------------------------------------------------------------------------
// 发送日志
// ---------------------------------------------------------------------------
/** /**
* 记录通知发送日志。 * 记录通知发送日志。
* *

View File

@@ -25,10 +25,10 @@ import { createWechatSender } from "./channels/wechat-channel"
import { createEmailSender } from "./channels/email-channel" import { createEmailSender } from "./channels/email-channel"
import { createInAppSender } from "./channels/in-app-channel" import { createInAppSender } from "./channels/in-app-channel"
import { import {
getUserNotificationPreferences,
getUserContactInfo, getUserContactInfo,
logNotificationSendBatch, logNotificationSendBatch,
} from "./data-access" } from "./data-access"
import { getNotificationPreferences } from "./preferences"
/** 渠道发送器实例缓存(避免每次发送重新创建) */ /** 渠道发送器实例缓存(避免每次发送重新创建) */
interface SenderRegistry { interface SenderRegistry {
@@ -109,7 +109,7 @@ export async function sendNotification(
// 并行获取用户偏好和联系方式 // 并行获取用户偏好和联系方式
const [prefs, contact] = await Promise.all([ const [prefs, contact] = await Promise.all([
getUserNotificationPreferences(userId), getNotificationPreferences(userId),
getUserContactInfo(userId), getUserContactInfo(userId),
]) ])

View File

@@ -3,7 +3,9 @@
* *
* 对外导出: * 对外导出:
* - sendNotification / sendBatchNotifications: 分发器入口 * - sendNotification / sendBatchNotifications: 分发器入口
* - 类型定义: NotificationPayload, ChannelSendResult, NotificationChannel 等 * - createNotification / getNotifications / markNotificationAsRead / markAllNotificationsAsRead / getUnreadNotificationCount: 站内通知 CRUD
* - getNotificationPreferences / upsertNotificationPreferences: 通知偏好 CRUD
* - 类型定义: NotificationPayload, ChannelSendResult, NotificationChannel, NotificationType, Notification, NotificationPreferences 等
* - 渠道发送器工厂: createSmsSender, createWechatSender, createEmailSender, createInAppSender * - 渠道发送器工厂: createSmsSender, createWechatSender, createEmailSender, createInAppSender
* *
* 典型用法: * 典型用法:
@@ -20,6 +22,20 @@
*/ */
export { sendNotification, sendBatchNotifications } from "./dispatcher" export { sendNotification, sendBatchNotifications } from "./dispatcher"
export {
createNotification,
getNotifications,
markNotificationAsRead,
markAllNotificationsAsRead,
getUnreadNotificationCount,
getUserContactInfo,
logNotificationSend,
logNotificationSendBatch,
} from "./data-access"
export {
getNotificationPreferences,
upsertNotificationPreferences,
} from "./preferences"
export type { export type {
NotificationChannel, NotificationChannel,
NotificationPayload, NotificationPayload,
@@ -28,6 +44,13 @@ export type {
SmsChannelConfig, SmsChannelConfig,
WechatChannelConfig, WechatChannelConfig,
EmailChannelConfig, EmailChannelConfig,
NotificationType,
Notification,
PaginatedResult,
GetNotificationsParams,
CreateNotificationInput,
NotificationPreferences,
UpdateNotificationPreferencesInput,
} from "./types" } from "./types"
export type { NotificationChannelSender, ChannelRecipient } from "./channels/types" export type { NotificationChannelSender, ChannelRecipient } from "./channels/types"

View 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
}
}

View File

@@ -6,17 +6,41 @@
* - NotificationPayload: 通知负载(跨渠道统一) * - NotificationPayload: 通知负载(跨渠道统一)
* - ChannelSendResult: 单次发送结果 * - ChannelSendResult: 单次发送结果
* - NotificationChannelConfig: 渠道配置(从环境变量加载) * - NotificationChannelConfig: 渠道配置(从环境变量加载)
*
* 此外,本文件还定义了站内通知记录与通知偏好的类型:
* - NotificationType / Notification: 站内通知记录message_notifications 表)
* - NotificationPreferences / UpdateNotificationPreferencesInput: 通知偏好notification_preferences 表)
* - CreateNotificationInput / GetNotificationsParams: 通知 CRUD 入参
* - PaginatedResult<T>: 分页结果泛型
*/ */
/** 支持的通知渠道 */ /** 支持的通知渠道 */
export type NotificationChannel = "in_app" | "email" | "sms" | "wechat" 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 { export interface NotificationPayload {
userId: string userId: string
title: string title: string
content: string content: string
/** 通知语义类型(用于渠道内模板映射,不与 messaging.NotificationType 耦合) */ /** 通知语义类型(用于渠道内模板映射,不与 NotificationType 耦合) */
type: "info" | "warning" | "error" | "success" type: "info" | "warning" | "error" | "success"
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
/** 点击通知后的跳转地址(站内相对路径或外链) */ /** 点击通知后的跳转地址(站内相对路径或外链) */
@@ -34,6 +58,59 @@ export interface ChannelSendResult {
sentAt: Date 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 渠道配置 */ /** SMS 渠道配置 */
export interface SmsChannelConfig { export interface SmsChannelConfig {
provider: "aliyun" | "tencent" | "mock" provider: "aliyun" | "tencent" | "mock"

View File

@@ -1,23 +1,25 @@
import "server-only" import "server-only"
import { cache } from "react" import { cache } from "react"
import { and, asc, eq } from "drizzle-orm" import { asc, eq } from "drizzle-orm"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { parentStudentRelations } from "@/shared/db/schema"
import { import {
classes, getClassNameById,
classEnrollments, getStudentActiveClassId,
grades, getStudentClasses,
parentStudentRelations, getStudentSchedule,
users, } from "@/modules/classes/data-access"
} from "@/shared/db/schema"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { import {
getStudentDashboardGrades, getStudentDashboardGrades,
getStudentHomeworkAssignments, getStudentHomeworkAssignments,
} from "@/modules/homework/data-access" } from "@/modules/homework/data-access"
import { getStudentGradeSummary } from "@/modules/grades/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 { import type {
ChildBasicInfo,
ChildDashboardData, ChildDashboardData,
ChildHomeworkSummary, ChildHomeworkSummary,
ChildScheduleItem, ChildScheduleItem,
@@ -25,9 +27,15 @@ import type {
ParentDashboardData, ParentDashboardData,
} from "./types" } 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() 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[]> => { export const getChildren = cache(async (parentId: string): Promise<ParentChildRelation[]> => {
@@ -55,53 +63,30 @@ export const getChildren = cache(async (parentId: string): Promise<ParentChildRe
})) }))
}) })
export const getChildBasicInfo = cache(async (studentId: string, relation: string | null = null) => { export const getChildBasicInfo = cache(
const [student] = await db async (
.select({ studentId: string,
id: users.id, relation: string | null = null,
name: users.name, ): Promise<ChildBasicInfo | null> => {
email: users.email, const student = await getUserBasicInfo(studentId)
image: users.image,
gradeId: users.gradeId,
})
.from(users)
.where(eq(users.id, studentId))
.limit(1)
if (!student) return null if (!student) return null
// gradeName 与 classId 相互独立,并行拉取
const [gradeOptions, classId] = await Promise.all([
student.gradeId ? getGradeOptions() : Promise.resolve([]),
getStudentActiveClassId(studentId),
])
let gradeName: string | null = null let gradeName: string | null = null
if (student.gradeId) { if (student.gradeId) {
const [grade] = await db const grade = gradeOptions.find((g) => g.id === student.gradeId)
.select({ name: grades.name })
.from(grades)
.where(eq(grades.id, student.gradeId))
.limit(1)
gradeName = grade?.name ?? null gradeName = grade?.name ?? null
} }
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 className: string | null = null
let classId: string | null = null if (classId) {
if (enrollment) { className = await getClassNameById(classId)
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
}
} }
return { return {
@@ -114,7 +99,8 @@ export const getChildBasicInfo = cache(async (studentId: string, relation: strin
classId, classId,
relation, relation,
} }
}) },
)
const buildHomeworkSummary = ( const buildHomeworkSummary = (
assignments: Awaited<ReturnType<typeof getStudentHomeworkAssignments>>, assignments: Awaited<ReturnType<typeof getStudentHomeworkAssignments>>,
@@ -211,12 +197,12 @@ export const getParentDashboardData = cache(
const id = parentId.trim() const id = parentId.trim()
if (!id) return { parentName: null, children: [] } if (!id) return { parentName: null, children: [] }
const [parent, relations] = await Promise.all([ const [parentInfo, relations] = await Promise.all([
db.select({ name: users.name }).from(users).where(eq(users.id, id)).limit(1), getUserNamesByIds([id]),
getChildren(id), getChildren(id),
]) ])
const parentName = parent[0]?.name ?? null const parentName = parentInfo.get(id)?.name ?? null
if (relations.length === 0) { if (relations.length === 0) {
return { parentName, children: [] } return { parentName, children: [] }

View File

@@ -1,28 +1,23 @@
"use server" "use server"
import { ActionState } from "@/shared/types/action-state" import { revalidatePath } from "next/cache"
import type { ActionState } from "@/shared/types/action-state"
import { import {
requirePermission, requirePermission,
requireAuth,
PermissionDeniedError, PermissionDeniedError,
} from "@/shared/lib/auth-guard" } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions" import { Permissions } from "@/shared/types/permissions"
import { z } from "zod" import { z } from "zod"
import { db } from "@/shared/db"
import { examSubmissions } from "@/shared/db/schema"
import { and, eq } from "drizzle-orm"
import { import {
recordProctoringEvent, recordProctoringEvent,
getExamSubmissionForProctoring,
getExamProctoringSummary, getExamProctoringSummary,
getStudentProctoringStatuses, getStudentProctoringStatuses,
getRecentProctoringEvents, getRecentProctoringEvents,
getExamForProctoring, getExamForProctoring,
} from "./data-access" } from "./data-access"
import type { import type { ProctoringDashboardData } from "./types"
ProctoringDashboardData,
ProctoringEventType,
} from "./types"
const ProctoringEventSchema = z.object({ const ProctoringEventSchema = z.object({
submissionId: z.string().min(1), submissionId: z.string().min(1),
@@ -36,7 +31,7 @@ const ProctoringEventSchema = z.object({
"devtools_open", "devtools_open",
"fullscreen_exit", "fullscreen_exit",
"idle_timeout", "idle_timeout",
]) as z.ZodType<ProctoringEventType>, ]),
eventDetail: z.string().optional(), eventDetail: z.string().optional(),
}) })
@@ -53,14 +48,14 @@ const successState = <T>(data: T, message?: string): ActionState<T> => ({
/** /**
* 学生端上报监考事件 * 学生端上报监考事件
* 使用 requireAuth() 因为是学生上报自己的事件,不需要管理权限 * 需要 EXAM_SUBMIT 权限(学生上报自己的事件
*/ */
export async function recordProctoringEventAction( export async function recordProctoringEventAction(
prevState: ActionState<{ id: string }> | null, prevState: ActionState<{ id: string }> | null,
formData: FormData, formData: FormData,
): Promise<ActionState<{ id: string }>> { ): Promise<ActionState<{ id: string }>> {
try { try {
const ctx = await requireAuth() const ctx = await requirePermission(Permissions.EXAM_SUBMIT)
const parsed = ProctoringEventSchema.safeParse({ const parsed = ProctoringEventSchema.safeParse({
submissionId: formData.get("submissionId"), submissionId: formData.get("submissionId"),
@@ -76,12 +71,10 @@ export async function recordProctoringEventAction(
} }
// 安全校验submission 必须属于当前学生 // 安全校验submission 必须属于当前学生
const submission = await db.query.examSubmissions.findFirst({ const submission = await getExamSubmissionForProctoring(
where: and( parsed.data.submissionId,
eq(examSubmissions.id, parsed.data.submissionId), ctx.userId,
eq(examSubmissions.studentId, ctx.userId), )
),
})
if (!submission) { if (!submission) {
return failState<{ id: string }>("Submission not found for current user") return failState<{ id: string }>("Submission not found for current user")
} }
@@ -94,6 +87,8 @@ export async function recordProctoringEventAction(
eventDetail: parsed.data.eventDetail, eventDetail: parsed.data.eventDetail,
}) })
revalidatePath(`/teacher/exams/${parsed.data.examId}/proctoring`)
return successState({ id: event.id }, "Event recorded") return successState({ id: event.id }, "Event recorded")
} catch (error) { } catch (error) {
if (error instanceof PermissionDeniedError) { if (error instanceof PermissionDeniedError) {

View File

@@ -1,16 +1,19 @@
import "server-only" import "server-only"
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { import { examProctoringEvents } from "@/shared/db/schema"
exams,
examProctoringEvents,
examSubmissions,
users,
} from "@/shared/db/schema"
import { and, desc, eq, gte, lte, sql, inArray } from "drizzle-orm" import { and, desc, eq, gte, lte, sql, inArray } from "drizzle-orm"
import { cache } from "react" import { cache } from "react"
import { createId } from "@paralleldrive/cuid2" import { createId } from "@paralleldrive/cuid2"
import {
getExamForProctoringCrossModule,
getExamSubmissionForProctoringCrossModule,
getExamSubmissionsForExam,
getExamTitleById,
} from "@/modules/exams/data-access"
import { getUserNamesByIds } from "@/modules/users/data-access"
import type { import type {
ProctoringEvent, ProctoringEvent,
ProctoringEventWithDetails, ProctoringEventWithDetails,
@@ -21,6 +24,7 @@ import type {
ExamModeConfig, ExamModeConfig,
ProctoringEventType, ProctoringEventType,
ExamMode, ExamMode,
SubmissionStatus,
} from "./types" } from "./types"
import { ABNORMAL_EVENT_THRESHOLD } from "./types" import { ABNORMAL_EVENT_THRESHOLD } from "./types"
@@ -53,6 +57,24 @@ const toExamMode = (value: unknown): ExamMode => {
return "homework" 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 const rows = await db
.select({ .select({
event: examProctoringEvents, event: examProctoringEvents,
studentName: users.name,
examTitle: exams.title,
}) })
.from(examProctoringEvents) .from(examProctoringEvents)
.innerJoin(users, eq(users.id, examProctoringEvents.studentId))
.innerJoin(exams, eq(exams.id, examProctoringEvents.examId))
.where(and(...conditions)) .where(and(...conditions))
.orderBy(desc(examProctoringEvents.occurredAt)) .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) => ({ return rows.map((row) => ({
id: row.event.id, id: row.event.id,
submissionId: row.event.submissionId, submissionId: row.event.submissionId,
studentId: row.event.studentId, studentId: row.event.studentId,
examId: row.event.examId, examId: row.event.examId,
eventType: row.event.eventType as ProctoringEventType, eventType: row.event.eventType,
eventDetail: row.event.eventDetail, eventDetail: row.event.eventDetail,
occurredAt: row.event.occurredAt.toISOString(), occurredAt: row.event.occurredAt.toISOString(),
createdAt: row.event.createdAt.toISOString(), createdAt: row.event.createdAt.toISOString(),
studentName: row.studentName ?? "未知学生", studentName: userMap.get(row.event.studentId)?.name ?? "未知学生",
examTitle: row.examTitle, examTitle: resolvedExamTitle,
})) }))
}, },
) )
@@ -149,7 +176,7 @@ export const getProctoringEventsBySubmission = cache(
submissionId: row.submissionId, submissionId: row.submissionId,
studentId: row.studentId, studentId: row.studentId,
examId: row.examId, examId: row.examId,
eventType: row.eventType as ProctoringEventType, eventType: row.eventType,
eventDetail: row.eventDetail, eventDetail: row.eventDetail,
occurredAt: row.occurredAt.toISOString(), occurredAt: row.occurredAt.toISOString(),
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
@@ -162,65 +189,53 @@ export const getProctoringEventsBySubmission = cache(
*/ */
export const getExamProctoringSummary = cache( export const getExamProctoringSummary = cache(
async (examId: string): Promise<ExamProctoringSummary> => { async (examId: string): Promise<ExamProctoringSummary> => {
const exam = await db.query.exams.findFirst({ // 考试信息与提交记录相互独立,并行拉取
where: eq(exams.id, examId), const [exam, submissions] = await Promise.all([
columns: { getExamForProctoringCrossModule(examId),
id: true, getExamSubmissionsForExam(examId),
title: true, ])
examMode: true,
},
})
const examTitle = exam?.title ?? "未知考试" const examTitle = exam?.title ?? "未知考试"
const examMode = toExamMode(exam?.examMode) 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 totalStudents = submissions.length
const startedStudents = submissions.filter( // 单次遍历统计 started / submitted
(s) => s.status === "started", let startedStudents = 0
).length let submittedStudents = 0
const submittedStudents = submissions.filter( for (const s of submissions) {
(s) => s.status === "submitted" || s.status === "graded", if (s.status === "started") startedStudents += 1
).length if (s.status === "submitted" || s.status === "graded") submittedStudents += 1
}
// 按事件类型分组统计 // 按事件类型分组统计 与 按学生分组统计 相互独立,并行拉取
const eventStats = await db const [eventStats, studentEventCounts] = await Promise.all([
db
.select({ .select({
eventType: examProctoringEvents.eventType, eventType: examProctoringEvents.eventType,
count: sql<number>`count(*)::int`, count: sql<number>`count(*)::int`,
}) })
.from(examProctoringEvents) .from(examProctoringEvents)
.where(eq(examProctoringEvents.examId, examId)) .where(eq(examProctoringEvents.examId, examId))
.groupBy(examProctoringEvents.eventType) .groupBy(examProctoringEvents.eventType),
db
const eventsByType = emptyEventsByType()
let totalEvents = 0
for (const stat of eventStats) {
const type = stat.eventType as ProctoringEventType
if (eventsByType[type] !== undefined) {
eventsByType[type] = stat.count
totalEvents += stat.count
}
}
// 统计异常学生数(事件数 >= 阈值)
const studentEventCounts = await db
.select({ .select({
studentId: examProctoringEvents.studentId, studentId: examProctoringEvents.studentId,
count: sql<number>`count(*)::int`, count: sql<number>`count(*)::int`,
}) })
.from(examProctoringEvents) .from(examProctoringEvents)
.where(eq(examProctoringEvents.examId, examId)) .where(eq(examProctoringEvents.examId, examId))
.groupBy(examProctoringEvents.studentId) .groupBy(examProctoringEvents.studentId),
])
const eventsByType = emptyEventsByType()
let totalEvents = 0
for (const stat of eventStats) {
const type = stat.eventType
if (eventsByType[type] !== undefined) {
eventsByType[type] = stat.count
totalEvents += stat.count
}
}
const abnormalStudents = studentEventCounts.filter( const abnormalStudents = studentEventCounts.filter(
(s) => s.count >= ABNORMAL_EVENT_THRESHOLD, (s) => s.count >= ABNORMAL_EVENT_THRESHOLD,
@@ -245,21 +260,17 @@ export const getExamProctoringSummary = cache(
*/ */
export const getStudentProctoringStatuses = cache( export const getStudentProctoringStatuses = cache(
async (examId: string): Promise<StudentProctoringStatus[]> => { async (examId: string): Promise<StudentProctoringStatus[]> => {
// 1. 拉取所有提交记录及学生姓名 // 1. 拉取所有提交记录
const submissions = await db const submissions = await getExamSubmissionsForExam(examId)
.select({
submission: examSubmissions,
studentName: users.name,
})
.from(examSubmissions)
.innerJoin(users, eq(users.id, examSubmissions.studentId))
.where(eq(examSubmissions.examId, examId))
if (submissions.length === 0) return [] 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 const eventRows = await db
.select({ .select({
studentId: examProctoringEvents.studentId, studentId: examProctoringEvents.studentId,
@@ -275,7 +286,7 @@ export const getStudentProctoringStatuses = cache(
) )
.orderBy(desc(examProctoringEvents.occurredAt)) .orderBy(desc(examProctoringEvents.occurredAt))
// 3. 按学生聚合 // 4. 按学生聚合
const statsByStudent = new Map< const statsByStudent = new Map<
string, string,
{ {
@@ -287,7 +298,7 @@ export const getStudentProctoringStatuses = cache(
for (const row of eventRows) { for (const row of eventRows) {
const sid = row.studentId const sid = row.studentId
const type = row.eventType as ProctoringEventType const type = row.eventType
const existing = statsByStudent.get(sid) ?? { const existing = statsByStudent.get(sid) ?? {
count: 0, count: 0,
lastEventAt: null, lastEventAt: null,
@@ -303,14 +314,14 @@ export const getStudentProctoringStatuses = cache(
statsByStudent.set(sid, existing) statsByStudent.set(sid, existing)
} }
return submissions.map((row) => { return submissions.map((submission) => {
const studentId = row.submission.studentId const studentId = submission.studentId
const stats = statsByStudent.get(studentId) const stats = statsByStudent.get(studentId)
return { return {
studentId, studentId,
studentName: row.studentName ?? "未知学生", studentName: userMap.get(studentId)?.name ?? "未知学生",
submissionId: row.submission.id, submissionId: submission.id,
submissionStatus: (row.submission.status ?? null) as StudentProctoringStatus["submissionStatus"], submissionStatus: toSubmissionStatusNullable(submission.status ?? null),
eventCount: stats?.count ?? 0, eventCount: stats?.count ?? 0,
lastEventAt: stats?.lastEventAt ? stats.lastEventAt.toISOString() : null, lastEventAt: stats?.lastEventAt ? stats.lastEventAt.toISOString() : null,
isAbnormal: (stats?.count ?? 0) >= ABNORMAL_EVENT_THRESHOLD, isAbnormal: (stats?.count ?? 0) >= ABNORMAL_EVENT_THRESHOLD,
@@ -330,9 +341,7 @@ export const getExamForProctoring = cache(
examMode: ExamMode examMode: ExamMode
config: ExamModeConfig config: ExamModeConfig
} | null> => { } | null> => {
const exam = await db.query.exams.findFirst({ const exam = await getExamForProctoringCrossModule(examId)
where: eq(exams.id, examId),
})
if (!exam) return null if (!exam) return null
@@ -360,27 +369,32 @@ export const getRecentProctoringEvents = cache(
const rows = await db const rows = await db
.select({ .select({
event: examProctoringEvents, event: examProctoringEvents,
studentName: users.name,
examTitle: exams.title,
}) })
.from(examProctoringEvents) .from(examProctoringEvents)
.innerJoin(users, eq(users.id, examProctoringEvents.studentId))
.innerJoin(exams, eq(exams.id, examProctoringEvents.examId))
.where(eq(examProctoringEvents.examId, examId)) .where(eq(examProctoringEvents.examId, examId))
.orderBy(desc(examProctoringEvents.occurredAt)) .orderBy(desc(examProctoringEvents.occurredAt))
.limit(limit) .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) => ({ return rows.map((row) => ({
id: row.event.id, id: row.event.id,
submissionId: row.event.submissionId, submissionId: row.event.submissionId,
studentId: row.event.studentId, studentId: row.event.studentId,
examId: row.event.examId, examId: row.event.examId,
eventType: row.event.eventType as ProctoringEventType, eventType: row.event.eventType,
eventDetail: row.event.eventDetail, eventDetail: row.event.eventDetail,
occurredAt: row.event.occurredAt.toISOString(), occurredAt: row.event.occurredAt.toISOString(),
createdAt: row.event.createdAt.toISOString(), createdAt: row.event.createdAt.toISOString(),
studentName: row.studentName ?? "未知学生", studentName: userMap.get(row.event.studentId)?.name ?? "未知学生",
examTitle: row.examTitle, examTitle: resolvedExamTitle,
})) }))
}, },
) )

View File

@@ -4,7 +4,7 @@ import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guar
import { Permissions } from "@/shared/types/permissions"; import { Permissions } from "@/shared/types/permissions";
import { CreateQuestionSchema } from "./schema"; import { CreateQuestionSchema } from "./schema";
import type { CreateQuestionInput } 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 { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { import {
@@ -17,6 +17,12 @@ import {
} from "./data-access"; } from "./data-access";
import type { KnowledgePointOption } from "./types"; 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( export async function createNestedQuestion(
prevState: ActionState<string> | undefined, prevState: ActionState<string> | undefined,
formData: FormData | CreateQuestionInput 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 { try {
await requirePermission(Permissions.QUESTION_READ); await requirePermission(Permissions.QUESTION_READ);
return await getQuestions(params); const data = await getQuestions(params);
return { success: true, data };
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) { 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 { try {
await requirePermission(Permissions.QUESTION_READ); await requirePermission(Permissions.QUESTION_READ);
return await getKnowledgePointOptions(); const data = await getKnowledgePointOptions();
return { success: true, data };
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) { 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 };
} }
} }

View File

@@ -160,8 +160,8 @@ export function CreateQuestionDialog({
if (!open) return if (!open) return
setIsLoadingKnowledgePoints(true) setIsLoadingKnowledgePoints(true)
getKnowledgePointOptionsAction() getKnowledgePointOptionsAction()
.then((rows) => { .then((result) => {
setKnowledgePointOptions(rows) setKnowledgePointOptions(result.success && result.data ? result.data : [])
}) })
.catch(() => { .catch(() => {
toast.error("Failed to load knowledge points") toast.error("Failed to load knowledge points")

View File

@@ -25,8 +25,8 @@ export function QuestionFilters() {
useEffect(() => { useEffect(() => {
getKnowledgePointOptionsAction() getKnowledgePointOptionsAction()
.then((rows) => { .then((result) => {
setKnowledgePointOptions(rows) setKnowledgePointOptions(result.success && result.data ? result.data : [])
}) })
.catch(() => { .catch(() => {
setKnowledgePointOptions([]) setKnowledgePointOptions([])

View File

@@ -297,3 +297,43 @@ export async function getKnowledgePointOptions(): Promise<KnowledgePointOption[]
grade: row.grade ?? null, 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
}
)

View File

@@ -3,7 +3,7 @@ import { z } from "zod"
export const QuestionTypeEnum = z.enum(["single_choice", "multiple_choice", "text", "judgment", "composite"]) export const QuestionTypeEnum = z.enum(["single_choice", "multiple_choice", "text", "judgment", "composite"])
export const BaseQuestionSchema = z.object({ 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, type: QuestionTypeEnum,
difficulty: z.number().min(1).max(5).default(1), difficulty: z.number().min(1).max(5).default(1),
knowledgePointIds: z.array(z.string()).optional(), knowledgePointIds: z.array(z.string()).optional(),

View File

@@ -1,14 +1,12 @@
"use server" "use server"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { eq, or } from "drizzle-orm"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions" import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state" import type { ActionState } from "@/shared/types/action-state"
import { db } from "@/shared/db" import { getUserNamesByIds } from "@/modules/users/data-access"
import { users } from "@/shared/db/schema"
import { import {
getSchedulingRules, getSchedulingRules,
@@ -107,14 +105,8 @@ export async function autoScheduleAction(
const teacherIds = Array.from( const teacherIds = Array.from(
new Set(subjectRows.map((r) => r.teacherId).filter((v): v is string => v !== null)) new Set(subjectRows.map((r) => r.teacherId).filter((v): v is string => v !== null))
) )
const teacherRows = const teacherMap = teacherIds.length > 0 ? await getUserNamesByIds(teacherIds) : new Map()
teacherIds.length > 0 const teachersInput = teacherIds.map((id) => ({ id, name: teacherMap.get(id)?.name ?? "Unknown" }))
? 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" }))
// Load classrooms // Load classrooms
const classroomRows = await getClassroomsForScheduling() const classroomRows = await getClassroomsForScheduling()

View File

@@ -141,8 +141,9 @@ export function validateSchedule(
// Pairwise overlap check (class/teacher/classroom) // Pairwise overlap check (class/teacher/classroom)
for (let i = 0; i < schedule.length; i += 1) { for (let i = 0; i < schedule.length; i += 1) {
for (let j = i + 1; j < schedule.length; j += 1) { for (let j = i + 1; j < schedule.length; j += 1) {
const a = schedule[i]! const a = schedule[i]
const b = schedule[j]! const b = schedule[j]
if (!a || !b) continue
if (!isOverlap(a, b)) continue if (!isOverlap(a, b)) continue
if (a.teacherId && a.teacherId === b.teacherId) { if (a.teacherId && a.teacherId === b.teacherId) {

View 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)
}

View File

@@ -132,12 +132,13 @@ export async function getScheduleChanges(
const userMap = new Map<string, string>() const userMap = new Map<string, string>()
if (userIds.length > 0) { if (userIds.length > 0) {
const firstId = userIds[0]
const userRows = await db const userRows = await db
.select({ id: users.id, name: users.name }) .select({ id: users.id, name: users.name })
.from(users) .from(users)
.where( .where(
userIds.length === 1 userIds.length === 1 && firstId
? eq(users.id, userIds[0]!) ? eq(users.id, firstId)
: or(...userIds.map((id) => eq(users.id, id))) : or(...userIds.map((id) => eq(users.id, id)))
) )
for (const u of userRows) userMap.set(u.id, u.name ?? "Unknown") 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[] = [] const conflicts: ScheduleConflict[] = []
for (let i = 0; i < rows.length; i += 1) { for (let i = 0; i < rows.length; i += 1) {
for (let j = i + 1; j < rows.length; j += 1) { for (let j = i + 1; j < rows.length; j += 1) {
const a = rows[i]! const a = rows[i]
const b = rows[j]! const b = rows[j]
if (!a || !b) continue
if (a.weekday !== b.weekday) continue if (a.weekday !== b.weekday) continue
// Time overlap: a.start < b.end && b.start < a.end // Time overlap: a.start < b.end && b.start < a.end
if (a.startTime < b.endTime && b.startTime < a.endTime) { 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 --- // --- 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 return await db
.select({ id: classes.id, name: classes.name, grade: classes.grade }) .select({ id: classes.id, name: classes.name, grade: classes.grade })
.from(classes) .from(classes)
.orderBy(classes.grade, classes.name) .orderBy(classes.grade, classes.name)
} }
export async function getTeachersForScheduling() { export async function getTeachersForScheduling(): Promise<SchedulingTeacherOption[]> {
return await db return await db
.select({ id: users.id, name: users.name, email: users.email }) .select({ id: users.id, name: users.name, email: users.email })
.from(users) .from(users)
@@ -252,14 +282,16 @@ export async function getTeachersForScheduling() {
.orderBy(users.name) .orderBy(users.name)
} }
export async function getClassroomsForScheduling() { export async function getClassroomsForScheduling(): Promise<SchedulingClassroomOption[]> {
return await db return await db
.select({ id: classrooms.id, name: classrooms.name, building: classrooms.building }) .select({ id: classrooms.id, name: classrooms.name, building: classrooms.building })
.from(classrooms) .from(classrooms)
.orderBy(classrooms.name) .orderBy(classrooms.name)
} }
export async function getClassSubjectsForScheduling(classId: string) { export async function getClassSubjectsForScheduling(
classId: string
): Promise<SchedulingClassSubject[]> {
return await db return await db
.select({ .select({
subjectId: subjects.id, subjectId: subjects.id,

View File

@@ -2,6 +2,26 @@
export type ScheduleChangeStatus = "pending" | "approved" | "rejected" | "completed" 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 { export interface SchedulingRule {
id: string id: string
classId: string | null classId: string | null

View File

@@ -1,16 +1,28 @@
"use server" "use server"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { after } from "next/server"
import { createId } from "@paralleldrive/cuid2" 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 type { ActionState } from "@/shared/types/action-state"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions" import { Permissions } from "@/shared/types/permissions"
import { logAudit } from "@/shared/lib/audit-logger" import { logAudit } from "@/shared/lib/audit-logger"
import { UpsertAcademicYearSchema, UpsertDepartmentSchema, UpsertGradeSchema, UpsertSchoolSchema } from "./schema" 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( export async function createDepartmentAction(
prevState: ActionState<string> | undefined, prevState: ActionState<string> | undefined,
@@ -23,7 +35,7 @@ export async function createDepartmentAction(
description: formData.get("description"), description: formData.get("description"),
}) })
await db.insert(departments).values({ await createDepartment({
id: createId(), id: createId(),
name: parsed.name, name: parsed.name,
description: parsed.description ?? null, description: parsed.description ?? null,
@@ -50,13 +62,10 @@ export async function updateDepartmentAction(
description: formData.get("description"), description: formData.get("description"),
}) })
await db await updateDepartment(departmentId, {
.update(departments)
.set({
name: parsed.name, name: parsed.name,
description: parsed.description ?? null, description: parsed.description ?? null,
}) })
.where(eq(departments.id, departmentId))
revalidatePath("/admin/school/departments") revalidatePath("/admin/school/departments")
return { success: true, message: "Department updated" } return { success: true, message: "Department updated" }
@@ -70,7 +79,7 @@ export async function updateDepartmentAction(
export async function deleteDepartmentAction(departmentId: string): Promise<ActionState<string>> { export async function deleteDepartmentAction(departmentId: string): Promise<ActionState<string>> {
try { try {
await requirePermission(Permissions.SCHOOL_MANAGE) await requirePermission(Permissions.SCHOOL_MANAGE)
await db.delete(departments).where(eq(departments.id, departmentId)) await deleteDepartment(departmentId)
revalidatePath("/admin/school/departments") revalidatePath("/admin/school/departments")
return { success: true, message: "Department deleted" } return { success: true, message: "Department deleted" }
} catch (error) { } catch (error) {
@@ -93,19 +102,13 @@ export async function createAcademicYearAction(
isActive: formData.get("isActive") ?? "false", isActive: formData.get("isActive") ?? "false",
}) })
await db.transaction(async (tx) => { await createAcademicYear({
if (parsed.isActive) {
await tx.update(academicYears).set({ isActive: false })
}
await tx.insert(academicYears).values({
id: createId(), id: createId(),
name: parsed.name, name: parsed.name,
startDate: new Date(parsed.startDate), startDate: new Date(parsed.startDate),
endDate: new Date(parsed.endDate), endDate: new Date(parsed.endDate),
isActive: parsed.isActive, isActive: parsed.isActive,
}) })
})
revalidatePath("/admin/school/academic-year") revalidatePath("/admin/school/academic-year")
return { success: true, message: "Academic year created" } return { success: true, message: "Academic year created" }
@@ -130,21 +133,12 @@ export async function updateAcademicYearAction(
isActive: formData.get("isActive") ?? "false", isActive: formData.get("isActive") ?? "false",
}) })
await db.transaction(async (tx) => { await updateAcademicYear(academicYearId, {
if (parsed.isActive) {
await tx.update(academicYears).set({ isActive: false })
}
await tx
.update(academicYears)
.set({
name: parsed.name, name: parsed.name,
startDate: new Date(parsed.startDate), startDate: new Date(parsed.startDate),
endDate: new Date(parsed.endDate), endDate: new Date(parsed.endDate),
isActive: parsed.isActive, isActive: parsed.isActive,
}) })
.where(eq(academicYears.id, academicYearId))
})
revalidatePath("/admin/school/academic-year") revalidatePath("/admin/school/academic-year")
return { success: true, message: "Academic year updated" } return { success: true, message: "Academic year updated" }
@@ -158,7 +152,7 @@ export async function updateAcademicYearAction(
export async function deleteAcademicYearAction(academicYearId: string): Promise<ActionState<string>> { export async function deleteAcademicYearAction(academicYearId: string): Promise<ActionState<string>> {
try { try {
await requirePermission(Permissions.SCHOOL_MANAGE) await requirePermission(Permissions.SCHOOL_MANAGE)
await db.delete(academicYears).where(eq(academicYears.id, academicYearId)) await deleteAcademicYear(academicYearId)
revalidatePath("/admin/school/academic-year") revalidatePath("/admin/school/academic-year")
return { success: true, message: "Academic year deleted" } return { success: true, message: "Academic year deleted" }
} catch (error) { } catch (error) {
@@ -179,13 +173,15 @@ export async function createSchoolAction(
code: formData.get("code"), code: formData.get("code"),
}) })
await db.insert(schools).values({ await createSchool({
id: createId(), id: createId(),
name: parsed.name, name: parsed.name,
code: parsed.code?.trim() ? parsed.code.trim() : null, 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") revalidatePath("/admin/school/schools")
return { success: true, message: "School created" } return { success: true, message: "School created" }
@@ -208,15 +204,20 @@ export async function updateSchoolAction(
code: formData.get("code"), code: formData.get("code"),
}) })
await db await updateSchool(schoolId, {
.update(schools)
.set({
name: parsed.name, name: parsed.name,
code: parsed.code?.trim() ? parsed.code.trim() : null, code: parsed.code?.trim() ? parsed.code.trim() : null,
}) })
.where(eq(schools.id, schoolId))
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") revalidatePath("/admin/school/schools")
return { success: true, message: "School updated" } return { success: true, message: "School updated" }
@@ -230,9 +231,11 @@ export async function updateSchoolAction(
export async function deleteSchoolAction(schoolId: string): Promise<ActionState<string>> { export async function deleteSchoolAction(schoolId: string): Promise<ActionState<string>> {
try { try {
await requirePermission(Permissions.SCHOOL_MANAGE) 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/schools")
revalidatePath("/admin/school/grades") revalidatePath("/admin/school/grades")
@@ -258,7 +261,7 @@ export async function createGradeAction(
teachingHeadId: formData.get("teachingHeadId"), teachingHeadId: formData.get("teachingHeadId"),
}) })
await db.insert(grades).values({ await createGrade({
id: createId(), id: createId(),
schoolId: parsed.schoolId, schoolId: parsed.schoolId,
name: parsed.name, name: parsed.name,
@@ -291,16 +294,13 @@ export async function updateGradeAction(
teachingHeadId: formData.get("teachingHeadId"), teachingHeadId: formData.get("teachingHeadId"),
}) })
await db await updateGrade(gradeId, {
.update(grades)
.set({
schoolId: parsed.schoolId, schoolId: parsed.schoolId,
name: parsed.name, name: parsed.name,
order: parsed.order, order: parsed.order,
gradeHeadId: parsed.gradeHeadId, gradeHeadId: parsed.gradeHeadId,
teachingHeadId: parsed.teachingHeadId, teachingHeadId: parsed.teachingHeadId,
}) })
.where(eq(grades.id, gradeId))
revalidatePath("/admin/school/grades") revalidatePath("/admin/school/grades")
return { success: true, message: "Grade updated" } return { success: true, message: "Grade updated" }
@@ -314,7 +314,7 @@ export async function updateGradeAction(
export async function deleteGradeAction(gradeId: string): Promise<ActionState<string>> { export async function deleteGradeAction(gradeId: string): Promise<ActionState<string>> {
try { try {
await requirePermission(Permissions.GRADE_MANAGE) await requirePermission(Permissions.GRADE_MANAGE)
await db.delete(grades).where(eq(grades.id, gradeId)) await deleteGrade(gradeId)
revalidatePath("/admin/school/grades") revalidatePath("/admin/school/grades")
return { success: true, message: "Grade deleted" } return { success: true, message: "Grade deleted" }
} catch (error) { } catch (error) {

View File

@@ -1,11 +1,25 @@
import "server-only" import "server-only"
import { cache } from "react" 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 { db } from "@/shared/db"
import { academicYears, departments, grades, roles, schools, users, usersToRoles } from "@/shared/db/schema" import { academicYears, departments, grades, roles, schools, subjects, users, usersToRoles } from "@/shared/db/schema"
import type { AcademicYearListItem, DepartmentListItem, GradeListItem, SchoolListItem, StaffOption } from "./types" import type {
AcademicYearInsertData,
AcademicYearListItem,
AcademicYearUpdateData,
DepartmentInsertData,
DepartmentListItem,
DepartmentUpdateData,
GradeInsertData,
GradeListItem,
GradeUpdateData,
SchoolInsertData,
SchoolListItem,
SchoolUpdateData,
StaffOption,
} from "./types"
const toIso = (d: Date) => d.toISOString() const toIso = (d: Date) => d.toISOString()
@@ -19,7 +33,8 @@ export const getDepartments = cache(async (): Promise<DepartmentListItem[]> => {
createdAt: toIso(r.createdAt), createdAt: toIso(r.createdAt),
updatedAt: toIso(r.updatedAt), updatedAt: toIso(r.updatedAt),
})) }))
} catch { } catch (error) {
console.error("getDepartments failed:", error)
return [] return []
} }
}) })
@@ -36,7 +51,8 @@ export const getAcademicYears = cache(async (): Promise<AcademicYearListItem[]>
createdAt: toIso(r.createdAt), createdAt: toIso(r.createdAt),
updatedAt: toIso(r.updatedAt), updatedAt: toIso(r.updatedAt),
})) }))
} catch { } catch (error) {
console.error("getAcademicYears failed:", error)
return [] return []
} }
}) })
@@ -51,7 +67,8 @@ export const getSchools = cache(async (): Promise<SchoolListItem[]> => {
createdAt: toIso(r.createdAt), createdAt: toIso(r.createdAt),
updatedAt: toIso(r.updatedAt), updatedAt: toIso(r.updatedAt),
})) }))
} catch { } catch (error) {
console.error("getSchools failed:", error)
return [] return []
} }
}) })
@@ -104,7 +121,8 @@ export const getGrades = cache(async (): Promise<GradeListItem[]> => {
createdAt: toIso(r.createdAt), createdAt: toIso(r.createdAt),
updatedAt: toIso(r.updatedAt), updatedAt: toIso(r.updatedAt),
})) }))
} catch { } catch (error) {
console.error("getGrades failed:", error)
return [] return []
} }
}) })
@@ -125,7 +143,8 @@ export const getStaffOptions = cache(async (): Promise<StaffOption[]> => {
name: r.name ?? "Unnamed", name: r.name ?? "Unnamed",
email: r.email, email: r.email,
})) }))
} catch { } catch (error) {
console.error("getStaffOptions failed:", error)
return [] return []
} }
}) })
@@ -180,7 +199,272 @@ export const getGradesForStaff = cache(async (staffId: string): Promise<GradeLis
createdAt: toIso(r.createdAt), createdAt: toIso(r.createdAt),
updatedAt: toIso(r.updatedAt), updatedAt: toIso(r.updatedAt),
})) }))
} catch { } catch (error) {
console.error("getGradesForStaff failed:", error)
return [] 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
}

View File

@@ -40,3 +40,57 @@ export type GradeListItem = {
createdAt: string createdAt: string
updatedAt: 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
}

View File

@@ -1,33 +1,40 @@
"use server" "use server"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { eq } from "drizzle-orm"
import { compare, hash } from "bcryptjs" 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 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 { validatePassword } from "@/shared/lib/password-policy"
import { rateLimit, rateLimitKey, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit" import { rateLimit, rateLimitKey, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit"
import { normalizeBcryptHash } from "@/shared/lib/bcrypt-utils"
const normalizeBcryptHash = (value: string) => { import {
if (value.startsWith("$2")) return value getPasswordSecurityByUserId,
if (value.startsWith("$")) return `$2b${value}` getUserPasswordHash,
return `$2b$${value}` 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 * Change the current user's password. Requires self-service profile update
* (no specific permission) since every user can manage their own * permission (every authenticated user has it). Rate-limited to slow
* credentials. Rate-limited to slow brute-force of the current password. * brute-force of the current password.
*/ */
export async function changePasswordAction( export async function changePasswordAction(
prevState: ActionState<null>, prevState: ActionState<null>,
formData: FormData formData: FormData
): Promise<ActionState<null>> { ): Promise<ActionState<null>> {
try { try {
const ctx = await requireAuth() const ctx = await requirePermission(Permissions.USER_PROFILE_UPDATE)
const userId = ctx.userId const userId = ctx.userId
const limitKey = rateLimitKey("pwd-change", 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." } return { success: false, message: "Too many attempts. Please try again later." }
} }
const currentPassword = String(formData.get("currentPassword") ?? "") const parsed = ChangePasswordSchema.safeParse({
const newPassword = String(formData.get("newPassword") ?? "") currentPassword: formData.get("currentPassword"),
const confirmPassword = String(formData.get("confirmPassword") ?? "") newPassword: formData.get("newPassword"),
confirmPassword: formData.get("confirmPassword"),
if (!currentPassword || !newPassword || !confirmPassword) { })
return { success: false, message: "All fields are required" } if (!parsed.success) {
return {
success: false,
message: parsed.error.issues[0]?.message ?? "Invalid form data",
} }
}
const { currentPassword, newPassword, confirmPassword } = parsed.data
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
return { success: false, message: "New passwords do not match" } 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" } return { success: false, message: validation.errors[0] ?? "Password does not meet requirements" }
} }
const [user] = await db // Parallelize user and passwordSecurity queries
.select({ id: users.id, password: users.password }) const [userRecord, existingSecurity] = await Promise.all([
.from(users) getUserPasswordHash(userId),
.where(eq(users.id, userId)) getPasswordSecurityByUserId(userId),
.limit(1) ])
if (!user || !user.password) {
if (!userRecord || !userRecord.password) {
return { success: false, message: "User not found or no password set" } 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")) { if (!storedHash.startsWith("$2")) {
return { success: false, message: "Stored password is invalid" } return { success: false, message: "Stored password is invalid" }
} }
@@ -75,34 +89,8 @@ export async function changePasswordAction(
const newHash = await hash(newPassword, 10) const newHash = await hash(newPassword, 10)
const now = new Date() const now = new Date()
await db await updateUserPassword(userId, newHash, now)
.update(users) await upsertPasswordSecurityOnPasswordChange(userId, now, existingSecurity)
.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,
})
}
revalidatePath("/settings") revalidatePath("/settings")
return { success: true, message: "Password changed successfully", data: null } return { success: true, message: "Password changed successfully", data: null }

View File

@@ -3,15 +3,23 @@
import { z } from "zod" import { z } from "zod"
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { createId } from "@paralleldrive/cuid2" 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 type { ActionState } from "@/shared/types/action-state"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions" import { Permissions } from "@/shared/types/permissions"
import { encryptAiApiKey, getAiErrorMessage, testAiProviderById, testAiProviderConfig } from "@/shared/lib/ai" 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 ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom"])
const AiProviderFormSchema = z.object({ const AiProviderFormSchema = z.object({
@@ -35,22 +43,12 @@ const AiProviderTestSchema = AiProviderFormSchema.extend({
} }
}) })
export type AiProviderSummary = { const ensureUser = async (): Promise<{ id: string }> => {
id: string
provider: z.infer<typeof ProviderSchema>
baseUrl: string | null
model: string
apiKeyLast4: string | null
isDefault: boolean
updatedAt: Date
}
const ensureUser = async () => {
const ctx = await requirePermission(Permissions.AI_CONFIGURE) const ctx = await requirePermission(Permissions.AI_CONFIGURE)
return { id: ctx.userId } return { id: ctx.userId }
} }
const normalizeBaseUrl = (value: string | undefined) => { const normalizeBaseUrl = (value: string | undefined): string | null => {
const raw = String(value ?? "").trim() const raw = String(value ?? "").trim()
if (!raw.length) return null if (!raw.length) return null
const trimmed = raw.replace(/\/+$/, "") const trimmed = raw.replace(/\/+$/, "")
@@ -61,19 +59,7 @@ const normalizeBaseUrl = (value: string | undefined) => {
export async function getAiProviderSummaries(): Promise<AiProviderSummary[]> { export async function getAiProviderSummaries(): Promise<AiProviderSummary[]> {
await ensureUser() await ensureUser()
const rows = await db return fetchAiProviderSummaries()
.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 upsertAiProviderAction( export async function upsertAiProviderAction(
@@ -92,51 +78,39 @@ export async function upsertAiProviderAction(
return { success: false, message: "Base URL is required for this provider" } return { success: false, message: "Base URL is required for this provider" }
} }
const [defaultRow] = await db // Parallelize default-count and existing-provider queries
.select({ value: count() }) const [defaultCount, existing] = await Promise.all([
.from(aiProviders) countDefaultAiProviders(),
.where(eq(aiProviders.isDefault, true)) payload.id ? getAiProviderForUpdate(payload.id) : Promise.resolve(null),
const defaultCount = Number(defaultRow?.value ?? 0) ])
const hasDefault = defaultCount > 0 const hasDefault = defaultCount > 0
if (payload.id) { if (payload.id) {
const id = 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" } if (!existing) return { success: false, message: "AI provider not found" }
const nextKey = payload.apiKey?.trim() const nextKey = payload.apiKey?.trim()
const encrypted = nextKey ? encryptAiApiKey(nextKey) : existing.apiKeyEncrypted const encrypted = nextKey ? encryptAiApiKey(nextKey) : existing.apiKeyEncrypted
const last4 = nextKey ? nextKey.slice(-4) : existing.apiKeyLast4 const last4 = nextKey ? nextKey.slice(-4) : existing.apiKeyLast4
const nextIsDefault = const isNextDefault =
payload.isDefault === false && existing.isDefault && defaultCount <= 1 ? true : payload.isDefault ?? existing.isDefault payload.isDefault === false && existing.isDefault && defaultCount <= 1
? true
: payload.isDefault ?? existing.isDefault
await db.transaction(async (tx) => { await updateAiProvider(
if (payload.isDefault) { id,
await tx.update(aiProviders).set({ isDefault: false }).where(eq(aiProviders.isDefault, true)) {
}
await tx
.update(aiProviders)
.set({
provider: payload.provider, provider: payload.provider,
baseUrl, baseUrl,
model: payload.model, model: payload.model,
apiKeyEncrypted: encrypted, apiKeyEncrypted: encrypted,
apiKeyLast4: last4, apiKeyLast4: last4,
isDefault: nextIsDefault, isDefault: isNextDefault,
updatedBy: user.id, updatedBy: user.id,
}) },
.where(eq(aiProviders.id, id)) payload.isDefault === true
}) )
revalidatePath("/settings") revalidatePath("/settings")
return { success: true, message: "AI provider updated", data: id } return { success: true, message: "AI provider updated", data: id }
@@ -149,24 +123,22 @@ export async function upsertAiProviderAction(
const id = createId() const id = createId()
const encrypted = encryptAiApiKey(payload.apiKey.trim()) const encrypted = encryptAiApiKey(payload.apiKey.trim())
const last4 = payload.apiKey.trim().slice(-4) const last4 = payload.apiKey.trim().slice(-4)
const makeDefault = payload.isDefault ?? !hasDefault const shouldMakeDefault = payload.isDefault ?? !hasDefault
await db.transaction(async (tx) => { await createAiProvider(
if (makeDefault) { {
await tx.update(aiProviders).set({ isDefault: false }).where(eq(aiProviders.isDefault, true))
}
await tx.insert(aiProviders).values({
id, id,
provider: payload.provider, provider: payload.provider,
baseUrl, baseUrl,
model: payload.model, model: payload.model,
apiKeyEncrypted: encrypted, apiKeyEncrypted: encrypted,
apiKeyLast4: last4, apiKeyLast4: last4,
isDefault: makeDefault, isDefault: shouldMakeDefault,
createdBy: user.id, createdBy: user.id,
updatedBy: user.id, updatedBy: user.id,
}) },
}) shouldMakeDefault
)
revalidatePath("/settings") revalidatePath("/settings")
return { success: true, message: "AI provider created", data: id } return { success: true, message: "AI provider created", data: id }

View File

@@ -47,14 +47,18 @@ export function ProfileSettingsForm({ user }: { user: UserProfile }) {
function onSubmit(data: ProfileFormValues) { function onSubmit(data: ProfileFormValues) {
startTransition(async () => { startTransition(async () => {
try { try {
await updateUserProfile({ const result = await updateUserProfile({
name: data.name, name: data.name,
phone: data.phone || undefined, phone: data.phone || undefined,
address: data.address || undefined, address: data.address || undefined,
gender: data.gender || undefined, gender: data.gender || undefined,
age: data.age || undefined, age: data.age || undefined,
}) })
if (result.success) {
toast.success("Profile updated successfully") toast.success("Profile updated successfully")
} else {
toast.error(result.message || "Failed to update profile")
}
} catch (error) { } catch (error) {
toast.error("Failed to update profile") toast.error("Failed to update profile")
console.error(error) console.error(error)

View 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,
})
}
}

View 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