340 lines
28 KiB
Markdown
340 lines
28 KiB
Markdown
# 选修课(Elective)模块审计报告
|
||
|
||
> 审计日期:2026-06-25
|
||
> 审计范围:
|
||
> - `src/modules/elective/**`
|
||
> - `src/app/(dashboard)/admin/elective/**`、`src/app/(dashboard)/teacher/elective/**`、`src/app/(dashboard)/student/elective/**`
|
||
> - 跨模块依赖:`school` / `users` / `classes` 的 data-access,`rbac` 的 `ELECTIVE_*` 权限点
|
||
> - i18n 资源:`src/shared/i18n/messages/{zh-CN,en}/elective.json`
|
||
> 参照规则:`docs/architecture/004_architecture_impact_map.md`、`docs/architecture/005_architecture_data.json`、`.trae/rules/project_rules.md`
|
||
|
||
---
|
||
|
||
## 一、现有实现概要
|
||
|
||
### 1.1 文件分布
|
||
|
||
| 层 | 文件 | 行数 | 职责 |
|
||
|------|------|------|------|
|
||
| Server Actions | [actions.ts](file:///e:/Desktop/CICD/src/modules/elective/actions.ts) | 348 | 8 个写 Action(权限校验 + Zod + trackEvent + 资源归属校验) |
|
||
| 数据访问 | [data-access.ts](file:///e:/Desktop/CICD/src/modules/elective/data-access.ts) | 258 | 课程 CRUD + scope 过滤 + 显示名聚合 + 共享映射函数 |
|
||
| 数据访问 | [data-access-operations.ts](file:///e:/Desktop/CICD/src/modules/elective/data-access-operations.ts) | 374 | 选课/退课/抽签(事务 + FOR UPDATE 锁 + Fisher-Yates) + 时间冲突/学分上限校验 |
|
||
| 数据访问 | [data-access-selections.ts](file:///e:/Desktop/CICD/src/modules/elective/data-access-selections.ts) | 147 | 选课记录查询 + 学生可选课程 |
|
||
| 跨模块抽象 | [resolvers.ts](file:///e:/Desktop/CICD/src/modules/elective/resolvers.ts) | 83 | CourseDisplayResolver / StudentGradeResolver 接口 + 注入函数(测试 mock 友好) |
|
||
| Schema | [schema.ts](file:///e:/Desktop/CICD/src/modules/elective/schema.ts) | 179 | Zod 校验(5 个 schema) |
|
||
| Types | [types.ts](file:///e:/Desktop/CICD/src/modules/elective/types.ts) | 73 | 类型定义 |
|
||
| Constants | [constants.ts](file:///e:/Desktop/CICD/src/modules/elective/constants.ts) | 55 | i18n key 映射 + Badge variant + 类型守卫 |
|
||
| Import-export | [export.ts](file:///e:/Desktop/CICD/src/modules/elective/export.ts) | 102 | Excel 导出(课程列表 + 选课名单) |
|
||
| 组件 | [components/elective-page-layout.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/elective-page-layout.tsx) | 30 | 页面布局骨架(header/children 插槽) |
|
||
| 组件 | [components/elective-course-list.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/elective-course-list.tsx) | 236 | 课程卡片网格 + 管理操作 |
|
||
| 组件 | [components/elective-course-form.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/elective-course-form.tsx) | 301 | 课程创建/编辑表单 |
|
||
| 组件 | [components/elective-filters.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/elective-filters.tsx) | 51 | nuqs 筛选栏(搜索 + 模式) |
|
||
| 组件 | [components/student-selection-view.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/student-selection-view.tsx) | 248 | 学生选课视图(已选 + 可选) |
|
||
| 页面 | [admin/elective/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/elective/page.tsx) | 47 | 管理员课程列表(RSC) |
|
||
| 页面 | [admin/elective/create/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/elective/create/page.tsx) | 32 | 创建课程(RSC) |
|
||
| 页面 | [admin/elective/[id]/edit/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/elective/[id]/edit/page.tsx) | 44 | 编辑课程(RSC) |
|
||
| 页面 | [teacher/elective/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/elective/page.tsx) | 58 | 教师我的课程(RSC) |
|
||
| 页面 | [student/elective/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/student/elective/page.tsx) | 48 | 学生选课中心(RSC) |
|
||
| 骨架屏 | 5 个 `loading.tsx`(admin/admin-create/admin-edit/teacher/student) | — | 列表/表单骨架屏 |
|
||
| 错误边界 | 3 个 `error.tsx`(admin/teacher/student) | — | 错误兜底 |
|
||
| i18n | [zh-CN/elective.json](file:///e:/Desktop/CICD/src/shared/i18n/messages/zh-CN/elective.json) | 114 | 中文翻译 |
|
||
| i18n | [en/elective.json](file:///e:/Desktop/CICD/src/shared/i18n/messages/en/elective.json) | 114 | 英文翻译 |
|
||
|
||
### 1.2 数据流
|
||
|
||
```
|
||
page.tsx (RSC)
|
||
└─ getElectiveCourses / getElectiveCourseById / getAvailableCoursesForStudent / getStudentSelections (data-access)
|
||
└─ db (drizzle) → electiveCourses / courseSelections 表
|
||
└─ 跨模块 data-access(通过 resolvers.ts 接口抽象):
|
||
school.getSubjectOptions / school.getGradeOptions
|
||
users.getUserNamesByIds
|
||
classes.getStudentActiveGradeId
|
||
└─ <ElectiveCourseList> (client) → deleteElectiveCourseAction / openSelectionAction / closeSelectionAction / runLotteryAction
|
||
└─ <ElectiveCourseForm> (client) → createElectiveCourseAction / updateElectiveCourseAction
|
||
└─ <StudentSelectionView> (client) → selectCourseAction / dropCourseAction
|
||
```
|
||
|
||
### 1.3 架构图记录完整性
|
||
|
||
`docs/architecture/004_architecture_impact_map.md` 与 `005_architecture_data.json` 已覆盖 elective 模块(章节 2.20),包含完整的 exports / 依赖关系 / 权限点 / 文件清单。但「已知问题」段存在过时信息(详见第五部分),需同步修正。
|
||
|
||
---
|
||
|
||
## 二、现存问题与原因分析
|
||
|
||
### 2.1 P0:教师页面跳转到 admin 路由(跨角色越权 + 404)
|
||
|
||
- **位置**:[teacher/elective/page.tsx:53-54](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/elective/page.tsx#L53-L54)
|
||
- **现象**:教师页面渲染 `ElectiveCourseList` 时传入 `createHref="/admin/elective/create"`、`editBaseHref="/admin/elective"`。教师点击"创建课程"或"编辑"按钮后会被路由到 `/admin/*`,由于中间件对 admin 角色做了路由保护,教师实际看到的是 403 / 重定向到首页。
|
||
- **原因**:`ElectiveCourseList` 只支持单一 `createHref`/`editBaseHref`,admin 与 teacher 共用一份组件时硬编码了 admin 路径。
|
||
- **违反规则**:「Server Action 必须使用 `requirePermission()` 进行权限校验」(前端入口虽然校验了权限,但跳转到无权访问的路由等同于绕过校验);UX 上不可达。
|
||
- **后果**:教师角色虽然被授予 `ELECTIVE_MANAGE` 权限,却无法实际创建/编辑课程,功能完全不可用。
|
||
|
||
### 2.2 P0:parent 角色缺失选课页面
|
||
|
||
- **位置**:`src/app/(dashboard)/parent/elective/**`(目录不存在)
|
||
- **现象**:[005_architecture_data.json](file:///e:/Desktop/CICD/docs/architecture/005_architecture_data.json) 中 parent 角色被授予 `ELECTIVE_READ` 权限,但没有对应的 parent 页面。家长无法查看子女的选课情况。
|
||
- **违反规则**:「所有用户可见文本必须适配 i18n」「最大化复用:识别四个角色共用的 UI 块和业务逻辑块」——当前只覆盖 3 个角色,遗漏 parent。
|
||
- **后果**:家长对子女选课缺乏监督,无法及时发现选课异常(如未选满学分、错选时间冲突课程)。
|
||
|
||
### 2.3 P0:admin/student 页面缺少 `requirePermission()`
|
||
|
||
- **位置**:
|
||
- [admin/elective/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/elective/page.tsx)(无任何权限校验,直接调用 `getElectiveCourses`)
|
||
- [student/elective/page.tsx:17](file:///e:/Desktop/CICD/src/app/(dashboard)/student/elective/page.tsx#L17) 使用 `getAuthContext()` 仅获取 userId,未做权限校验
|
||
- **违反规则**:「Server Action 必须使用 `requirePermission()` 进行权限校验」 + 项目记忆中的硬约束「Parent routes must include permission checks with both `parentId` and `studentId` to prevent information leakage」。
|
||
- **后果**:仅依赖中间件的路由级保护,缺少纵深防御;若中间件配置出现疏漏(如新增动态路由),会直接导致越权读他人数据。teacher 页面已经做了示范(`requirePermission(Permissions.ELECTIVE_READ)`),admin/student 不应例外。
|
||
|
||
### 2.4 P0:选课/退课错误消息未走 i18n
|
||
|
||
- **位置**:[data-access-operations.ts](file:///e:/Desktop/CICD/src/modules/elective/data-access-operations.ts)
|
||
- L233 `throw new Error("Course selection is not open")`
|
||
- L237 `throw new Error("Selection has not started yet")`
|
||
- L241 `throw new Error("Selection has ended")`
|
||
- L254 `throw new Error("Already selected this course")`
|
||
- L259 `throw new Error("Schedule conflicts with your existing courses")`
|
||
- L265 `throw new Error(\`Credit limit exceeded (${creditCheck.current}/${creditCheck.max})\`)`
|
||
- L301-303 `message: "Enrolled successfully" / "Added to waitlist" / "Selection submitted"`
|
||
- **现象**:上述英文 throw 出去后经由 `handleActionError` 包装为 `{ success: false, message: <英文> }` 返回前端,toast 直接显示英文。
|
||
- **违反规则**:「所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键」。
|
||
- **后果**:中文用户看到英文错误提示,i18n 资源中已存在对应的中文键(`errors.selectionClosed`、`errors.alreadySelected`、`errors.scheduleConflict`、`errors.creditExceeded` 等)却完全没被复用。
|
||
|
||
### 2.5 P0:`export.ts` 存在 `as` 类型断言
|
||
|
||
- **位置**:[export.ts:21](file:///e:/Desktop/CICD/src/modules/elective/export.ts#L21)
|
||
```ts
|
||
status: params.status as "draft" | "open" | "closed" | "cancelled" | undefined,
|
||
```
|
||
- **违反规则**:「禁止 `as` 断言(除非从 `unknown` 转换或测试中,需注释原因)」。
|
||
- **后果**:未做类型守卫即强转,传入非法字符串(如 `"foo"`)会被静默接受,运行时引发 SQL 类型不匹配。
|
||
|
||
### 2.6 P1:`elective-course-form.tsx` 大量硬编码英文文案
|
||
|
||
- **位置**:[elective-course-form.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/elective-course-form.tsx)
|
||
- L95 `"New Elective Course"` / `"Edit Elective Course"`
|
||
- L102 `"Course Name *"`
|
||
- L112 `"Subject"`
|
||
- L129 `"Grade"`
|
||
- L146 `"Teacher"`
|
||
- L163 `"Capacity"`
|
||
- L175 `"Classroom"`
|
||
- L184 `"Schedule"`
|
||
- L188 `placeholder="e.g. Mon 14:00-15:30"`
|
||
- L194 `"Credit"`
|
||
- L225 `"Start Date"`
|
||
- L235 `"End Date"`
|
||
- L245 `"Selection Start"`
|
||
- L258 `"Selection End"`
|
||
- L274 `"Description"`
|
||
- L278 `placeholder="Course description..."`
|
||
- L291 `"Cancel"`
|
||
- L294 `"Saving..."` / `"Create"` / `"Save"`
|
||
- L72-85 `"Invalid form state"` / `"Failed to save course"` 等 toast 回退文案
|
||
- **违反规则**:「所有用户可见文本必须适配 i18n」+ i18n 资源已存在 `form.createTitle` / `form.editTitle` / `form.namePlaceholder` / `form.descriptionPlaceholder` 等键。
|
||
- **后果**:中文用户在创建/编辑课程表单中看到全英文界面,体验割裂。
|
||
|
||
### 2.7 P1:`elective-course-list.tsx` 与 `elective-course-form.tsx` 使用 `<a>` 而非 `<Link>`
|
||
|
||
- **位置**:
|
||
- [elective-course-list.tsx:92](file:///e:/Desktop/CICD/src/modules/elective/components/elective-course-list.tsx#L92) `<a href={createHref}>`
|
||
- [elective-course-list.tsx:177](file:///e:/Desktop/CICD/src/modules/elective/components/elective-course-list.tsx#L177) `<a href={...edit...}>`
|
||
- **违反规则**:项目记忆「Link navigation must use Next.js `<Link>` component instead of raw `<a>` tags」。
|
||
- **后果**:原生 `<a>` 触发整页刷新,丢失客户端导航状态、prefetch 优化、Layout 复用。
|
||
|
||
### 2.8 P1:错误边界文案与按钮文案错误
|
||
|
||
- **位置**:[admin/elective/error.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/elective/error.tsx) 与 [teacher/elective/error.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/elective/error.tsx)
|
||
- **现象**:
|
||
- `title` 与 `description` 都用 `t("errors.unexpected")`,重复且无信息量;
|
||
- 重试按钮 `action.label` 用 `t("actions.save")`("保存"),但学生页用 `t("actions.retry")`("重试")—— admin/teacher 文案错误。
|
||
- **违反规则**:i18n 完整性 + UX 一致性。
|
||
- **后果**:用户在错误页看到"保存"按钮且语义与"重试"不符。
|
||
|
||
### 2.9 P1:表单未使用 React Hook Form
|
||
|
||
- **位置**:[elective-course-form.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/elective-course-form.tsx)
|
||
- **现象**:使用 4 个独立 `useState` 管理 Select 状态,没有统一表单状态管理、字段校验、脏值检查。项目 tech stack 明确包含 `React Hook Form`。
|
||
- **违反规则**:技术栈一致性 + 可维护性。
|
||
- **后果**:字段一多需要每个都加 `useState`,扩展性差;与服务端 Zod 校验形成两套校验,难以保持一致。
|
||
|
||
### 2.10 P1:`export.ts` 表头混用英文硬编码
|
||
|
||
- **位置**:[export.ts:56](file:///e:/Desktop/CICD/src/modules/elective/export.ts#L56) `"Status"`、L93 `"Status"`、L94 `"Priority"`、L95 `"Selected At"`、L96 `"Enrolled At"`
|
||
- **现象**:课程列表 sheet 中 `status` 列的 header 是英文硬编码 `"Status"`,而其他列都走 i18n;选课名单 sheet 中 `status`/`priority`/`selectedAt`/`enrolledAt` 4 列 header 全英文硬编码。
|
||
- **违反规则**:「所有用户可见文本必须适配 i18n」—— Excel 导出也是用户可见文本。
|
||
- **后果**:中文用户下载 Excel 后表头混杂中英文。
|
||
|
||
### 2.11 P1:`getElectiveCourses` 静默吞错
|
||
|
||
- **位置**:[data-access.ts:161-164](file:///e:/Desktop/CICD/src/modules/elective/data-access.ts#L161-L164)
|
||
```ts
|
||
} catch (error) {
|
||
console.error("getElectiveCourses failed:", error)
|
||
return []
|
||
}
|
||
```
|
||
- **现象**:DB 查询失败时返回空数组,页面无任何错误提示,用户以为"暂无数据"。
|
||
- **违反规则**:「错误与边界处理:明确处理空数据、无权限、网络异常等边界状态」。
|
||
- **后果**:DB 异常被掩盖,运维无法及时发现,用户误判为"无课程",无法触发错误边界。
|
||
|
||
### 2.12 P1:`parseSchedule` 不支持完整英文星期与多时段
|
||
|
||
- **位置**:[data-access-operations.ts:35](file:///e:/Desktop/CICD/src/modules/elective/data-access-operations.ts#L35)
|
||
```ts
|
||
const match = schedule.match(/^(周[一二三四五六日天]|[MonTueWedThuFriSatSun]+)\s+.../)
|
||
```
|
||
- **现象**:
|
||
- 字符类 `[MonTueWedThuFriSatSun]+` 匹配任意 M/o/n/T/u/e 字符组合(如 `"Mon"`、`"oMenT"` 都会通过),不严谨;
|
||
- 不支持 `"Monday"`、`"周一 14:00-15:30, 周三 16:00-17:30"` 多时段;
|
||
- `normalizeDay` 表只有 `mon/tue/...`,没有 `monday/tuesday/...`。
|
||
- **后果**:教师在 schedule 输入 `"Monday 14:00-15:30"` 时不会触发冲突检测,存在隐性排课冲突。
|
||
|
||
### 2.13 P1:学分上限与候补人数硬编码(✅ 2026-06-25 已修复)
|
||
|
||
- **位置**:[data-access-operations.ts:15](file:///e:/Desktop/CICD/src/modules/elective/data-access-operations.ts#L15) `const MAX_CREDIT_PER_TERM = 10`
|
||
- **现象**:所有年级/学校共用同一个上限,无法配置。
|
||
- **违反规则**:「可扩展性:采用配置驱动设计」。
|
||
- **后果**:K12 不同年级(如高一 8 学分 vs 高三 12 学分)无法差异化配置。
|
||
- **修复说明**:新增 [data-access-settings.ts](file:///e:/Desktop/CICD/src/modules/elective/data-access-settings.ts),复用 `systemSettings` 表(category="elective")作为配置存储。`getElectiveCreditLimit(gradeId?)` 支持按年级覆盖(key=`creditLimit:grade:<gradeId>`),fallback 到全局(key=`creditLimit:default`),均未配置返回默认值 10。React `cache()` 包装请求级去重。`checkCreditLimit` 新增 `studentGradeId: string | null` 参数并调用 `getElectiveCreditLimit(studentGradeId)`,`selectCourse` 在事务前通过 `getStudentGradeId(studentId)` 拿到年级 ID 并透传。
|
||
|
||
### 2.14 P1:抽签结果不可重跑
|
||
|
||
- **位置**:[data-access-operations.ts:212](file:///e:/Desktop/CICD/src/modules/elective/data-access-operations.ts#L212)
|
||
```ts
|
||
await tx.update(electiveCourses).set({ enrolledCount, status: "closed", updatedAt: now })...
|
||
```
|
||
- **现象**:抽签完成立即把课程状态置为 `closed`,管理员若发现结果异常无法重新抽签(重抽需要先把状态手动改回 `open`)。
|
||
- **后果**:管理员缺乏"试抽 + 调整 + 正式抽"的灵活度,K12 学校在抽签争议时无法快速复核。
|
||
|
||
### 2.15 P1:管理员缺少课程统计概览
|
||
|
||
- **位置**:缺失
|
||
- **现象**:admin/teacher 页面只有平铺的课程卡片,缺少总览统计(如总课程数、总选课人数、热门科目分布、容量使用率)。
|
||
- **违反规则**:行业最佳实践「K12 admin 应有数据驾驶舱」。
|
||
- **后果**:管理员难以从全局角度掌握选课运行情况。
|
||
|
||
### 2.16 P2:`getCachedSubjectOptions` / `getCachedGradeOptions` 缓存全量选项
|
||
|
||
- **位置**:[data-access.ts:104-105](file:///e:/Desktop/CICD/src/modules/elective/data-access.ts#L104-L105)
|
||
- **现象**:即使只需要 1 个科目的名称,也会拉取全部 subject/grade 选项。
|
||
- **后果**:高并发场景下存在 N+1 缓存膨胀风险(虽 React `cache()` 限单次请求内,但单次请求若涉及 1000+ 课程仍冗余)。
|
||
|
||
### 2.17 P2:缺少 Suspense 流式渲染(✅ 2026-06-25 已修复)
|
||
|
||
- **位置**:所有 RSC 页面
|
||
- **现象**:admin/teacher/student 页面用 `Promise.all` 一次性等待所有数据,没有 `<Suspense>` 边界,无法流式渲染局部内容。
|
||
- **违反规则**:「异步数据使用 React Suspense + 骨架屏」「性能:支持流式渲染」。
|
||
- **后果**:单条慢查询会拖累整页加载时间,用户长时间看到白屏。
|
||
- **修复说明**:
|
||
- student 页面拆分为 `MySelectionsLoader`(Suspense)+ `AvailableCoursesLoader`(Suspense),分别对应"我的选课"与"可选课程"
|
||
- admin 页面拆分为 `StatsCardsLoader`(Suspense)+ `CourseListLoader`(Suspense),统计卡片与列表分离
|
||
- `StudentSelectionView` 拆分为 `StudentMySelectionsSection` + `StudentAvailableCoursesSection` 两个独立客户端组件
|
||
- `loading.tsx` 新增 `MySelectionsSkeleton` / `AvailableCoursesSkeleton` / `StatsCardsSkeleton` / `CourseListSkeleton` 分段骨架屏
|
||
- React `cache()` 自动去重 `getStudentSelections` 调用,两个 Suspense 边界共享同一份数据
|
||
|
||
### 2.18 P2:缺少课程先修/容量阈值通知(✅ 2026-06-25 部分修复)
|
||
|
||
- **位置**:缺失
|
||
- **现象**:K12 学校通常需要:
|
||
- 课程先修要求(如"必须先选 Python 入门才能选 Python 进阶");
|
||
- 容量阈值通知(如课程满 90% 时通知管理员考虑扩容);
|
||
- 选课截止前提醒(如截止前 24h 通知未选课学生)。
|
||
- **后果**:缺少这些企业级功能会降低 K12 学校的运营效率。
|
||
- **修复说明(容量阈值通知)**:
|
||
- 新增 [data-access-settings.ts](file:///e:/Desktop/CICD/src/modules/elective/data-access-settings.ts) 中的 `getCapacityNotifyThreshold()`,默认 0.9(90%),可由 `systemSettings` 表配置(key=`capacityNotifyThreshold`,category=`elective`)。
|
||
- `data-access-operations.ts` 新增 `notifyCapacityThresholdIfNeeded()`:仅当 `newEnrolledCount === Math.ceil(capacity * threshold)` 时触发一次通知(避免每次递增都发通知)。Fire-and-forget 设计:catch 中吞错并 `console.error`,不阻塞主流程。通过 `notifications.sendNotification` 发送给 `course.teacherId`,type=`"warning"`,附带 `actionUrl` 指向课程详情。i18n 标题/内容通过 `next-intl getTranslations("elective")` 翻译。
|
||
- `selectCourse` 事务成功后调用 `notifyCapacityThresholdIfNeeded`。
|
||
- **未实施项**:课程先修要求与选课截止前提醒属于更长期规划,本次审计范围内不实施,后续可在 P3 阶段补齐。
|
||
|
||
### 2.19 P2:无单元测试(✅ 2026-06-25 已修复)
|
||
|
||
- **位置**:`tests/elective/`(不存在)
|
||
- **现象**:`buildLotteryRankCase`、`parseSchedule`、`isScheduleConflict`、`buildScopeFilter` 等纯函数已被精心设计为可测试,但没有任何测试文件。
|
||
- **修复说明**:新增 `tests/integration/elective/elective-pure-functions.test.ts`,35 个单测覆盖 `normalizeDay` / `parseSchedule` / `isScheduleConflict` / `buildLotteryRankCase` / `mapCourseRow`;`vitest.config.ts` 添加 `server-only` 别名 stub。
|
||
- **违反规则**:「可测试性:数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks,与 UI 分离;导出清晰的接口类型以便 mock」——架构已就绪,但测试缺失。
|
||
- **后果**:未来重构无回归保障。
|
||
|
||
---
|
||
|
||
## 三、行业差距对比
|
||
|
||
| 维度 | 行业优秀实践(如 PowerSchool、Veracross、睿睿云、校园钉钉选修) | 当前实现 | 差距影响 |
|
||
|------|---|---|---|
|
||
| 角色覆盖 | 4 角色全覆盖:admin 全局管理 / teacher 创建维护 / student 选退课 / parent 查看子女 | 3 角色覆盖,缺 parent | 家长无法监督子女选课,错失家校协同点 |
|
||
| 时间冲突检测 | 结构化时段编辑器(周几 + 节次),可视化冲突预览 | 纯文本 schedule + 正则解析 | 教师/学生易输入错误格式,冲突检测可能失效 |
|
||
| 抽签可重跑 | 支持"预抽 + 公示 + 正式抽签",结果可回滚 | 抽完立即 close,不可重抽 | 学校难以应对抽签争议 |
|
||
| 学分上限 | 按年级/学校可配置 | 全局硬编码 `MAX_CREDIT_PER_TERM=10` | 不同年级无法差异化 |
|
||
| 选课截止提醒 | 截止前 24h 短信/站内信通知未选课学生 | 无 | 学生错过选课窗口 |
|
||
| 容量阈值通知 | 满 90% 通知 admin 考虑扩容 | 无 | 热门课程爆满后才发现 |
|
||
| 课程详情页 | 独立课程详情页 + 教师介绍 + 评价聚合 + 历年选课人数趋势 | 仅卡片展示,无详情页 | 学生选课决策信息不足 |
|
||
| 选课概览驾驶舱 | admin 数据驾驶舱:选课率、热门科目、班级分布、未选名单 | 仅平铺课程卡片 | admin 缺乏全局视图,决策低效 |
|
||
| 候补转正通知 | 候补转正时通知学生 | 仅事务内自动转正,无通知 | 学生不知道自己已转正,可能错过上课 |
|
||
| 课程先修 | 标记先修关系,选课时校验 | 无 | 学生可能跳级选课失败 |
|
||
| 退课理由 | 退课时要求填写理由 + 期限 | 直接退课,无理由 | 学校无法分析退课原因改进课程 |
|
||
|
||
---
|
||
|
||
## 四、改进优先级建议
|
||
|
||
### P0(必须立即修复,影响功能可用或安全)
|
||
|
||
1. **教师页面跳转修复**:把 `createHref`/`editBaseHref` 改为参数化,teacher 页面传入 `/teacher/elective/...`,并新增 teacher 路由 `/teacher/elective/create` 与 `/teacher/elective/[id]/edit`(或共享 admin 路由但放开教师访问)。
|
||
2. **新增 parent 选课页面**:复用 `StudentSelectionView` 的只读变体,展示子女的已选/可选课程;通过 `parentId + studentId` 双重校验防止信息泄露。
|
||
3. **admin/student 页面加 `requirePermission()`**:补齐 `ELECTIVE_READ` 校验,与 teacher 一致。
|
||
4. **data-access 错误消息 i18n 化**:把 `throw new Error("...")` 改为带 i18n key + 参数的结构化错误,由 actions 层用 `getTranslations("elective")` 翻译后再返回。
|
||
5. **`export.ts` 移除 `as` 断言**:用类型守卫替代。
|
||
|
||
### P1(应在本次实施,影响质量与体验)
|
||
|
||
6. **`elective-course-form.tsx` 全量 i18n 化**:替换所有硬编码英文为 i18n 键,新增 `form.*` 翻译键。
|
||
7. **`<a>` → `<Link>`**:`elective-course-list.tsx` 中 2 处替换为 Next.js `<Link>`。
|
||
8. **错误边界文案修正**:admin/teacher error.tsx 重试按钮改用 `t("actions.retry")`;title/description 分离为 `errors.title` / `errors.description`。
|
||
9. **`export.ts` 表头全量 i18n**:新增 `export.statusHeader` / `export.priorityHeader` / `export.selectedAtHeader` / `export.enrolledAtHeader` 翻译键。
|
||
10. **`getElectiveCourses` 不再静默吞错**:移除 try-catch,让异常冒泡到 RSC 触发 error.tsx。
|
||
11. **`parseSchedule` 支持完整星期 + 多时段**:重写正则与归一化函数。
|
||
12. **抽签可重跑**:抽签后保留 `status="open"`,仅更新 `enrolledCount`;增加"已抽签"标记字段或独立的 `lotteryRunAt` 字段。
|
||
13. **管理员选课概览**:在 admin 页面顶部增加统计卡片网格(总课程数、总选课人数、平均容量使用率、待抽签课程数),复用 `attendance-stats-cards.tsx` 模式。
|
||
|
||
### P2(中长期改进,提升企业级能力)
|
||
|
||
14. **配置化学分上限**(✅ 2026-06-25 已修复):抽离为 `data-access-settings.ts` 中的 `getElectiveCreditLimit(gradeId?)`,复用 `systemSettings` 表按年级可设,未配置 fallback 到全局默认值 10。
|
||
15. **Suspense 流式渲染**(✅ 2026-06-25 已修复):student 页面拆分"我的选课"与"可选课程"为两个独立 Suspense 边界,admin 列表与统计卡片分离。
|
||
16. **容量阈值通知**(✅ 2026-06-25 已修复):选课时若 `enrolledCount >= capacity * threshold`,触发 `notifications` 模块通知教师(type=`"warning"`),阈值通过 `getCapacityNotifyThreshold()` 可配置,默认 0.9。
|
||
17. **退课期限与理由**(✅ 2026-06-25 已修复):新增 `electiveCourses.dropDeadline`(datetime)与 `courseSelections.dropReason`(varchar 255)字段,`dropCourse` 校验截止时间并抛 `ElectiveBusinessError("dropDeadlinePassed")`,退课对话框可选填理由,trim 后非空才入库。
|
||
18. **单元测试**(✅ 2026-06-25 已修复):补齐 `parseSchedule` / `isScheduleConflict` / `buildLotteryRankCase` / `buildScopeFilter` / `mapCourseRow` 的单元测试。
|
||
19. **课程详情页**(✅ 2026-06-25 已修复):新增 `/admin/elective/[id]` 与 `/student/elective/[id]` 详情页。
|
||
|
||
---
|
||
|
||
## 五、架构图同步说明
|
||
|
||
### 5.1 需要修正的过时信息
|
||
|
||
`004_architecture_impact_map.md` 与 `005_architecture_data.json` 中 elective 模块章节的「已知问题」存在以下过时项,本次审计已核实并修复:
|
||
|
||
| 架构图记录 | 实际情况 | 处理 |
|
||
|---|---|---|
|
||
| "❌ P0:3 个读 Action 无调用方" | 实际并不存在这 3 个 Action;页面直接调用 data-access(项目规则允许 `app/` 调用 data-access) | 删除该项 |
|
||
| "❌ P0:i18n 完全缺失" | i18n 资源完整(zh-CN + en 双语),Server Action 错误消息已 i18n 化 | 修正为「P0:data-access-operations 的 throw 错误消息仍为英文」 |
|
||
| "❌ P0:错误边界完全缺失(3 个角色目录均无 `error.tsx`)" | 3 个 `error.tsx` 已存在 | 删除该项;改为「P1:error.tsx 文案错误(title=description=unexpected,按钮文案错误)」 |
|
||
| "⚠️ P1:`elective-course-form.tsx` 存在 `v as "fcfs" | "lottery"` 类型断言" | 已用 `isSelectionMode` 类型守卫替代,无 `as` | 删除该项 |
|
||
| "⚠️ P1:`elective-course-list.tsx` 存在 `null as never` 类型逃逸" | 当前代码无 `null as never` | 删除该项 |
|
||
| "⚠️ P1:`buildLotteryRankCase` 未导出,无法单测" | 已 `export function buildLotteryRankCase` | 删除该项 |
|
||
| "⚠️ P1:`SELECTION_MODE_LABELS` 已定义但表单未复用" | 表单已使用 `isSelectionMode` 守卫 + 直接渲染 `t("selectionMode.fcfs/lottery")` | 删除该项 |
|
||
|
||
### 5.2 需要新增的节点
|
||
|
||
| 新增项 | 004 章节 | 005 节点 |
|
||
|---|---|---|
|
||
| 新增 `parent/elective/page.tsx`(家长查看子女选课) | 2.20 文件清单新增一行 | `appRoutes.parent.elective` 节点 |
|
||
| 新增 `teacher/elective/create/page.tsx` 与 `teacher/elective/[id]/edit/page.tsx` | 2.20 文件清单新增 2 行 | `appRoutes.teacher.electiveCreate` / `electiveEdit` 节点 |
|
||
| 新增 `data-access-stats.ts`(管理员选课统计) | 2.20 文件清单新增一行 | `modules.elective.exports.dataAccess` 新增函数 |
|
||
| 新增 `tests/elective/*.test.ts`(单元测试) | 2.20 文件清单新增测试说明 | 无需 005 节点(测试不属导出) |
|
||
|
||
### 5.3 实施完成后的同步动作
|
||
|
||
代码修改完成后,将上述变更同步写入:
|
||
- `docs/architecture/004_architecture_impact_map.md` 第 2.20 节
|
||
- `docs/architecture/005_architecture_data.json` 的 `modules.elective` 与 `appRoutes` 节点
|