feat(attendance,elective): 考勤与选修课模块审计重构 — P0 修复 + i18n + Error Boundary
审计报告:docs/architecture/audit/attendance-elective-audit-report.md P0 修复: - attendance: getAttendanceStats 统计失真(仅基于前 20 条记录)改为 SQL 聚合查询 - attendance: getClassStudentsForAttendance 跨模块直查 classEnrollments 改为调用 classes data-access - attendance: update/delete Action 新增资源归属校验(assertRecordOwnership) - elective: update/delete/openSelection/closeSelection/runLottery Action 新增资源归属校验(assertCourseOwnership) i18n 接入: - 新增 attendance/elective 命名空间(zh-CN + en) - attendance-stats-cards 接入 useTranslations - elective-course-list/form 接入 useTranslations 类型安全(P1): - elective-course-form: 移除 as 断言,改用类型守卫 isSelectionMode - elective-course-list: 移除 null as never 类型逃逸,改用泛型 Error Boundary: - 新增 admin/teacher attendance error.tsx - 新增 admin/student elective error.tsx 架构图同步: - 004: 修正 attendance/elective/parent 章节的导出函数、文件清单、已知问题 - 005: 修正 actions 的 usedBy(标记无调用方的死代码)、新增 issues 字段、更新依赖矩阵
This commit is contained in:
769
docs/architecture/audit/attendance-elective-audit-report.md
Normal file
769
docs/architecture/audit/attendance-elective-audit-report.md
Normal file
@@ -0,0 +1,769 @@
|
||||
# 考勤与选修课(Attendance & Elective)模块审计报告
|
||||
|
||||
> 审计日期:2026-06-22
|
||||
> 审计范围:
|
||||
> - `src/modules/attendance/**`、`src/app/(dashboard)/admin/attendance/**`、`src/app/(dashboard)/teacher/attendance/**`、`src/app/(dashboard)/student/attendance/**`、`src/app/(dashboard)/parent/attendance/**`
|
||||
> - `src/modules/elective/**`、`src/app/(dashboard)/admin/elective/**`、`src/app/(dashboard)/teacher/elective/**`、`src/app/(dashboard)/student/elective/**`
|
||||
> - 跨模块依赖:`src/modules/parent/components/parent-attendance-*.tsx`、`src/shared/i18n/messages/**`
|
||||
> 参照规则:`docs/architecture/004_architecture_impact_map.md`、`docs/architecture/005_architecture_data.json`、`.trae/rules/project_rules.md`
|
||||
|
||||
---
|
||||
|
||||
## 一、现有实现概要
|
||||
|
||||
### 1.1 文件分布
|
||||
|
||||
#### 考勤模块(attendance)
|
||||
|
||||
| 层 | 文件 | 行数 | 职责 |
|
||||
|------|------|------|------|
|
||||
| Server Actions | [actions.ts](file:///e:/Desktop/CICD/src/modules/attendance/actions.ts) | 271 | 10 个 Server Action(含权限校验、Zod 校验) |
|
||||
| 数据访问 | [data-access.ts](file:///e:/Desktop/CICD/src/modules/attendance/data-access.ts) | 309 | 考勤记录 CRUD + 班级学生查询 + 规则 upsert + 总览统计 |
|
||||
| 数据访问 | [data-access-stats.ts](file:///e:/Desktop/CICD/src/modules/attendance/data-access-stats.ts) | 145 | 学生/班级考勤汇总(拆分范例) |
|
||||
| Schema | [schema.ts](file:///e:/Desktop/CICD/src/modules/attendance/schema.ts) | 43 | Zod 校验(5 个 schema) |
|
||||
| Types | [types.ts](file:///e:/Desktop/CICD/src/modules/attendance/types.ts) | 103 | 类型定义 + 状态标签/颜色常量 |
|
||||
| 组件 | [components/attendance-sheet.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-sheet.tsx) | 353 | 批量点名表单(键盘快捷键、状态按钮组) |
|
||||
| 组件 | [components/attendance-record-list.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-record-list.tsx) | 130 | 考勤记录列表 + 删除对话框 |
|
||||
| 组件 | [components/attendance-filters.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-filters.tsx) | 97 | URL 同步筛选器(班级/状态/日期) |
|
||||
| 组件 | [components/attendance-stats-card.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-stats-card.tsx) | 81 | 单卡片统计(8 指标) |
|
||||
| 组件 | [components/attendance-stats-cards.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-stats-cards.tsx) | 80 | 管理员总览 6 卡片网格 |
|
||||
| 组件 | [components/attendance-stats-class-selector.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-stats-class-selector.tsx) | 27 | 班级筛选 ChipNav |
|
||||
| 组件 | [components/attendance-rules-form.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-rules-form.tsx) | 148 | 考勤规则配置表单 |
|
||||
| 组件 | [components/student-attendance-view.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/student-attendance-view.tsx) | 104 | 学生/家长视图(统计 + 最近记录) |
|
||||
| 页面 | [admin/attendance/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/attendance/page.tsx) | 91 | 管理员考勤总览(RSC) |
|
||||
| 页面 | [teacher/attendance/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/attendance/page.tsx) | 116 | 教师考勤记录列表(RSC + 分页) |
|
||||
| 页面 | [teacher/attendance/sheet/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/attendance/sheet/page.tsx) | 44 | 教师点名页(RSC) |
|
||||
| 页面 | [teacher/attendance/stats/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/attendance/stats/page.tsx) | 85 | 教师班级考勤统计(RSC) |
|
||||
| 页面 | [student/attendance/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/student/attendance/page.tsx) | 40 | 学生考勤汇总(RSC) |
|
||||
| 页面 | [parent/attendance/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/parent/attendance/page.tsx) | 66 | 家长多子女考勤聚合(RSC) |
|
||||
| 骨架屏 | 2 个 `loading.tsx`(student/parent) | — | 列表骨架屏 |
|
||||
| 错误边界 | 0 个 `error.tsx` | — | **完全缺失** |
|
||||
|
||||
#### 选修课模块(elective)
|
||||
|
||||
| 层 | 文件 | 行数 | 职责 |
|
||||
|------|------|------|------|
|
||||
| Server Actions | [actions.ts](file:///e:/Desktop/CICD/src/modules/elective/actions.ts) | 304 | 11 个 Server Action |
|
||||
| 数据访问 | [data-access.ts](file:///e:/Desktop/CICD/src/modules/elective/data-access.ts) | 250 | 课程 CRUD + scope 过滤 + 显示名聚合 |
|
||||
| 数据访问 | [data-access-operations.ts](file:///e:/Desktop/CICD/src/modules/elective/data-access-operations.ts) | 245 | 选课/退课/抽签(事务 + FOR UPDATE 锁) |
|
||||
| 数据访问 | [data-access-selections.ts](file:///e:/Desktop/CICD/src/modules/elective/data-access-selections.ts) | 149 | 选课记录查询 + 学生可选课程 |
|
||||
| Schema | [schema.ts](file:///e:/Desktop/CICD/src/modules/elective/schema.ts) | 132 | Zod 校验(5 个 schema) |
|
||||
| Types | [types.ts](file:///e:/Desktop/CICD/src/modules/elective/types.ts) | 108 | 类型定义 + 4 组标签/颜色常量 |
|
||||
| 组件 | [components/elective-course-list.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/elective-course-list.tsx) | 233 | 课程卡片网格 + 管理操作 |
|
||||
| 组件 | [components/elective-course-form.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/elective-course-form.tsx) | 293 | 课程创建/编辑表单 |
|
||||
| 组件 | [components/elective-filters.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/elective-filters.tsx) | 49 | nuqs 筛选栏(搜索 + 模式) |
|
||||
| 组件 | [components/student-selection-view.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/student-selection-view.tsx) | 250 | 学生选课视图(已选 + 可选) |
|
||||
| 页面 | [admin/elective/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/elective/page.tsx) | 46 | 管理员课程列表(RSC) |
|
||||
| 页面 | [admin/elective/create/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/elective/create/page.tsx) | 36 | 创建课程(RSC) |
|
||||
| 页面 | [admin/elective/[id]/edit/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/elective/[id]/edit/page.tsx) | 48 | 编辑课程(RSC) |
|
||||
| 页面 | [teacher/elective/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/elective/page.tsx) | 53 | 教师我的课程(RSC) |
|
||||
| 页面 | [student/elective/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/student/elective/page.tsx) | 54 | 学生选课中心(RSC) |
|
||||
| 骨架屏 | 1 个 `loading.tsx`(student) | — | 列表骨架屏 |
|
||||
| 错误边界 | 0 个 `error.tsx` | — | **完全缺失** |
|
||||
|
||||
#### 跨模块依赖(parent 模块消费 attendance 类型)
|
||||
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| [parent/components/parent-attendance-warning.tsx](file:///e:/Desktop/CICD/src/modules/parent/components/parent-attendance-warning.tsx) | 102 | 家长考勤异常预警横幅 |
|
||||
| [parent/components/parent-attendance-rate-card.tsx](file:///e:/Desktop/CICD/src/modules/parent/components/parent-attendance-rate-card.tsx) | 114 | 家长出勤率汇总卡片 |
|
||||
| [parent/components/parent-attendance-calendar.tsx](file:///e:/Desktop/CICD/src/modules/parent/components/parent-attendance-calendar.tsx) | 194 | 家长考勤月历视图 |
|
||||
|
||||
### 1.2 数据流
|
||||
|
||||
#### 考勤数据流
|
||||
|
||||
```
|
||||
page.tsx (RSC)
|
||||
└─ getAttendanceRecords / getStudentAttendanceSummary / getClassAttendanceStats (data-access)
|
||||
└─ db (drizzle) → attendanceRecords / attendanceRules / classEnrollments / users / classes 表
|
||||
└─ <AttendanceSheet> (client) → batchRecordAttendanceAction
|
||||
└─ <AttendanceRecordList> (client) → deleteAttendanceAction
|
||||
└─ <AttendanceRulesForm> (client) → saveAttendanceRulesAction
|
||||
└─ <StudentAttendanceView> (server) — 学生/家长只读
|
||||
└─ <ParentAttendanceCalendar/Warning/RateCard> (server/client) — 家长聚合视图
|
||||
```
|
||||
|
||||
#### 选修课数据流
|
||||
|
||||
```
|
||||
page.tsx (RSC)
|
||||
└─ getElectiveCourses / getElectiveCourseById / getAvailableCoursesForStudent / getStudentSelections (data-access)
|
||||
└─ db (drizzle) → electiveCourses / courseSelections 表
|
||||
└─ 跨模块 data-access:school.getSubjectOptions / school.getGradeOptions / users.getUserNamesByIds / classes.getStudentActiveGradeId
|
||||
└─ <ElectiveCourseList> (client) → deleteElectiveCourseAction / openSelectionAction / closeSelectionAction / runLotteryAction
|
||||
└─ <ElectiveCourseForm> (client) → createElectiveCourseAction / updateElectiveCourseAction
|
||||
└─ <StudentSelectionView> (client) → selectCourseAction / dropCourseAction
|
||||
```
|
||||
|
||||
### 1.3 架构图记录完整性
|
||||
|
||||
经核对 [004_architecture_impact_map.md](file:///e:/Desktop/CICD/docs/architecture/004_architecture_impact_map.md) §2.10(attendance)与 §2.20(elective)以及 [005_architecture_data.json](file:///e:/Desktop/CICD/docs/architecture/005_architecture_data.json) 中对应节点,架构图记录**存在以下偏差**(详见第五节):
|
||||
|
||||
- **attendance 行数统计过期**:图记 `actions.ts 271 行 / data-access.ts 309 行`,实际一致;但 `data-access-stats.ts` 图记 145 行,实际 145 行(一致)。组件文件数图记 5 个,实际 8 个组件文件(缺 `attendance-record-list.tsx`、`attendance-rules-form.tsx`、`student-attendance-view.tsx`)。
|
||||
- **attendance 导出函数名不一致**:图记 Actions 含 `getAttendanceRecordsAction / createAttendanceRecordAction / updateAttendanceRecordAction / deleteAttendanceRecordAction / getStudentAttendanceAction / getAttendanceStatsAction`,实际为 `recordAttendanceAction / batchRecordAttendanceAction / updateAttendanceAction / deleteAttendanceAction / getAttendanceAction / getStudentAttendanceAction / getClassAttendanceStatsAction / getClassAttendanceForDateAction / saveAttendanceRulesAction / getAttendanceRulesAction`(10 个,名称与图不一致)。
|
||||
- **attendance 缺失组件记录**:图记 `AttendanceStatsCards` 一个组件,实际有 8 个组件(含 `AttendanceSheet`、`AttendanceRecordList`、`AttendanceFilters`、`AttendanceStatsCard`、`AttendanceStatsCards`、`AttendanceStatsClassSelector`、`AttendanceRulesForm`、`StudentAttendanceView`)。
|
||||
- **attendance 缺失规则功能记录**:架构图未记录 `attendanceRules` 表的 CRUD(实际已实现 `saveAttendanceRulesAction` / `getAttendanceRulesAction` + `upsertAttendanceRules` / `getAttendanceRules`)。
|
||||
- **elective 行数统计过期**:图记 `actions.ts 304 行 / data-access.ts 250 行 / data-access-operations.ts 245 行 / data-access-selections.ts 189 行`,实际 `data-access-selections.ts` 为 149 行(减少 40 行)。
|
||||
- **elective 缺失组件记录**:图记组件 3 个(`elective-course-form`、`elective-course-list`、`elective-filters`),实际 4 个(缺 `student-selection-view.tsx`)。
|
||||
- **elective 缺失 usedBy 信息**:`getStudentSelectionsAction` / `getAvailableCoursesAction` 的 `usedBy` 字段标注为"待扩展",实际已被 `student/elective/page.tsx` 通过 data-access 直接调用(绕过 Action)。
|
||||
- **parent 跨模块 UI 依赖未记录**:parent 模块的 3 个 attendance 组件直接 import `@/modules/attendance/types`,架构图未在 parent 模块的依赖关系中标注此 UI 层依赖。
|
||||
|
||||
---
|
||||
|
||||
## 二、现存问题与原因分析
|
||||
|
||||
### 2.1 架构解耦
|
||||
|
||||
#### 问题 2.1.1 | parent 模块跨模块 import attendance 类型(P1)
|
||||
|
||||
- **位置**:
|
||||
- [parent-attendance-warning.tsx#L5](file:///e:/Desktop/CICD/src/modules/parent/components/parent-attendance-warning.tsx#L5):`import type { StudentAttendanceSummary } from "@/modules/attendance/types"`
|
||||
- [parent-attendance-rate-card.tsx#L5](file:///e:/Desktop/CICD/src/modules/parent/components/parent-attendance-rate-card.tsx#L5):同上
|
||||
- [parent-attendance-calendar.tsx#L6-L10](file:///e:/Desktop/CICD/src/modules/parent/components/parent-attendance-calendar.tsx#L6):`import type { AttendanceListItem, AttendanceStatus, StudentAttendanceSummary } from "@/modules/attendance/types"`
|
||||
- **现象**:parent 模块的 3 个组件直接依赖 attendance 模块的类型定义,且 `parent-attendance-calendar.tsx` 内部重新定义了 `STATUS_LABEL` / `STATUS_DOT` 常量(与 attendance 模块的 `ATTENDANCE_STATUS_LABELS` / `ATTENDANCE_STATUS_COLORS` 重复)。
|
||||
- **违反规则**:项目规则"该模块必须作为独立功能单元……模块内部组件绝不直接 import 其他业务模块的 actions 或 data-access(只能通过注入的接口调用)"。虽然此处仅 import 类型,但 parent 模块应通过自身定义的视图模型接口解耦,而非直接消费 attendance 内部类型。
|
||||
- **原因**:家长考勤视图需要展示 attendance 数据,开发时直接复用 attendance 类型,未做视图模型隔离。
|
||||
- **后果**:attendance 模块修改 `StudentAttendanceSummary` 字段会破坏 parent 模块编译;parent 模块无法独立测试;新增角色时无法替换 attendance 数据源。
|
||||
|
||||
#### 问题 2.1.2 | 考勤页面层绕过 Action 直接调用 data-access(P2)
|
||||
|
||||
- **位置**:
|
||||
- [admin/attendance/page.tsx#L12](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/attendance/page.tsx#L12):`import { getAttendanceRecords, getAttendanceStats } from "@/modules/attendance/data-access"`
|
||||
- [teacher/attendance/page.tsx#L10](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/attendance/page.tsx#L10):`import { getAttendanceRecords } from "@/modules/attendance/data-access"`
|
||||
- [teacher/attendance/sheet/page.tsx#L3](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/attendance/sheet/page.tsx#L3):`import { getClassStudentsForAttendance } from "@/modules/attendance/data-access"`
|
||||
- [teacher/attendance/stats/page.tsx#L3](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/attendance/stats/page.tsx#L3):`import { getClassAttendanceStats } from "@/modules/attendance/data-access-stats"`
|
||||
- [student/attendance/page.tsx#L2](file:///e:/Desktop/CICD/src/app/(dashboard)/student/attendance/page.tsx#L2):`import { getStudentAttendanceSummary } from "@/modules/attendance/data-access-stats"`
|
||||
- [parent/attendance/page.tsx#L2](file:///e:/Desktop/CICD/src/app/(dashboard)/parent/attendance/page.tsx#L2):同上
|
||||
- **现象**:所有读操作页面(admin/teacher/student/parent)均直接调用 data-access,未走 `getAttendanceAction` / `getStudentAttendanceAction` / `getClassAttendanceStatsAction` 等 Server Action。
|
||||
- **违反规则**:项目规则"`app/` 只能调用 `modules/` 的 Server Actions 和 data-access"——此处虽合规(data-access 允许被 app 调用),但架构图 §2.10 标注的 10 个 Action 中有 6 个读 Action 实际无调用方(死代码),且页面层未享受 Action 的统一错误处理与权限二次校验。
|
||||
- **原因**:RSC 页面直接调 data-access 性能更优(少一层包装),但导致 Action 层读函数成为死代码。
|
||||
- **后果**:Action 层 6 个读函数(`getAttendanceAction` / `getStudentAttendanceAction` / `getClassAttendanceStatsAction` / `getClassAttendanceForDateAction` / `getAttendanceRulesAction`)无调用方,维护成本浪费;权限二次校验形同虚设。
|
||||
|
||||
#### 问题 2.1.3 | elective 页面层同样绕过 Action(P2)
|
||||
|
||||
- **位置**:
|
||||
- [admin/elective/page.tsx#L4](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/elective/page.tsx#L4):`import { getElectiveCourses } from "@/modules/elective/data-access"`
|
||||
- [admin/elective/[id]/edit/page.tsx#L5](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/elective/[id]/edit/page.tsx#L5):`import { getElectiveCourseById } from "@/modules/elective/data-access"`
|
||||
- [teacher/elective/page.tsx#L4](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/elective/page.tsx#L4):同 admin
|
||||
- [student/elective/page.tsx#L3](file:///e:/Desktop/CICD/src/app/(dashboard)/student/elective/page.tsx#L3):`import { getAvailableCoursesForStudent, getStudentSelections } from "@/modules/elective/data-access-selections"`
|
||||
- **现象**:与考勤相同,elective 的 3 个读 Action(`getElectiveCoursesAction` / `getStudentSelectionsAction` / `getAvailableCoursesAction`)无调用方。
|
||||
- **后果**:同 2.1.2。
|
||||
|
||||
#### 问题 2.1.4 | elective data-access 跨模块依赖未通过接口抽象(P2)
|
||||
|
||||
- **位置**:
|
||||
- [data-access.ts#L10-L11](file:///e:/Desktop/CICD/src/modules/elective/data-access.ts#L10):`import { getGradeOptions, getSubjectOptions } from "@/modules/school/data-access"`、`import { getUserNamesByIds } from "@/modules/users/data-access"`
|
||||
- [data-access-selections.ts#L12-L13](file:///e:/Desktop/CICD/src/modules/elective/data-access-selections.ts#L12):`import { getStudentActiveGradeId } from "@/modules/classes/data-access"`、`import { getUserNamesByIds } from "@/modules/users/data-access"`
|
||||
- **现象**:elective data-access 直接静态 import school/users/classes 模块的 data-access。
|
||||
- **违反规则**:项目规则"模块间只能通过对方 data-access 通信"——此处合规(data-access 层通信),但未通过接口抽象,导致 elective 模块无法独立测试(mock 需拦截具体路径)。
|
||||
- **原因**:架构图 §2.20 已标注这些跨模块依赖为"已修复"(从直查表改为 data-access),但未进一步抽象为接口。
|
||||
- **后果**:单测 elective 时需 mock 3 个模块的 data-access 函数;未来替换 school/users/classes 实现需改 elective 源码。
|
||||
|
||||
### 2.2 国际化(i18n)
|
||||
|
||||
#### 问题 2.2.1 | 考勤模块零 i18n 覆盖(P0)
|
||||
|
||||
- **位置**:模块全部 13 个源文件
|
||||
- **现象**:项目已接入 next-intl(见 [i18n/request.ts](file:///e:/Desktop/CICD/src/i18n/request.ts)),但考勤模块**没有任何一处**使用 `useTranslations` / `getTranslations`,所有文案硬编码,且中英文混杂:
|
||||
- 中文硬编码:`"考勤总览"`、`"查看全校所有班级的考勤记录"`、`"统计分析"`、`"暂无考勤记录"`、`"系统中尚未产生任何考勤记录。"`、`"考勤记录"`、`"管理学生考勤记录。"`、`"录入考勤"`、`"统计"`、`"当前班级有未保存的考勤记录,确认切换班级?"`、`"总记录数"`、`"出勤"`、`"缺勤"`、`"迟到"`、`"早退"`、`"出勤率"`([admin/attendance/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/attendance/page.tsx)、[teacher/attendance/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/attendance/page.tsx)、[attendance-sheet.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-sheet.tsx)、[attendance-stats-cards.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-stats-cards.tsx))
|
||||
- 英文硬编码:`"Attendance Sheet"`、`"Save Attendance"`、`"Saving..."`、`"Class"`、`"Date"`、`"Student"`、`"Email"`、`"Status"`、`"Mark All Present"`、`"Search student..."`、`"No students in this class..."`、`"Attendance Statistics"`、`"Present"`、`"Absent"`、`"Late"`、`"Early Leave"`、`"Excused"`、`"Total Records"`、`"Present Rate"`、`"Late Rate"`、`"No attendance data available."`、`"Recent Attendance"`、`"Attendance Rules"`、`"Save Rules"`、`"Late Threshold (minutes)"`、`"Early Leave Threshold (minutes)"`、`"Enable auto-marking..."`、`"Delete Attendance Record"`、`"Are you sure..."`、`"My Attendance"`、`"View your attendance records and statistics."`、`"No attendance records found."`、`"No data"`、`"Student attendance summary is not available."`、`"Recorded By"`、`"Created"`([attendance-sheet.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-sheet.tsx)、[attendance-record-list.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-record-list.tsx)、[attendance-stats-card.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-stats-card.tsx)、[attendance-rules-form.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-rules-form.tsx)、[student-attendance-view.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/student-attendance-view.tsx)、[attendance-filters.tsx](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-filters.tsx))
|
||||
- 状态标签常量硬编码英文:`ATTENDANCE_STATUS_LABELS` 在 [types.ts#L86-L92](file:///e:/Desktop/CICD/src/modules/attendance/types.ts#L86) 直接写死 `"Present"` / `"Absent"` / `"Late"` / `"Early Leave"` / `"Excused"`,未走 i18n。
|
||||
- **违反规则**:项目规则"所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键"。
|
||||
- **原因**:模块开发时未跟进 i18n 改造,文案随写随定。
|
||||
- **后果**:无法切换语言;同一界面中英混杂(管理员页中文、教师点名页英文、统计卡片中文),专业度差;后续做国际化需返工全部组件。
|
||||
|
||||
#### 问题 2.2.2 | 选修课模块零 i18n 覆盖(P0)
|
||||
|
||||
- **位置**:模块全部 10 个源文件
|
||||
- **现象**:与考勤模块相同,选修课模块无任何 i18n 调用,文案中英混杂:
|
||||
- 中文硬编码:`"选修课程"`、`"管理选修课程、开放/关闭选课与抽签。"`、`"新建选修课程"`、`"创建新的选修课程。"`、`"编辑选修课程"`、`"更新选修课程详情。"`([admin/elective/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/elective/page.tsx)、[admin/elective/create/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/elective/create/page.tsx)、[admin/elective/[id]/edit/page.tsx](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/elective/[id]/edit/page.tsx))
|
||||
- 英文硬编码:`"My Elective Courses"`、`"View and manage the elective courses you teach."`、`"Elective Courses"`、`"Browse available electives and manage your selections."`、`"New Course"`、`"No elective courses"`、`"There are no elective courses available."`、`"Credit"`、`"Teacher"`、`"Mode"`、`"Capacity"`、`"Room"`、`"Schedule"`、`"Open"`、`"Close"`、`"Lottery"`、`"Edit"`、`"Delete"`、`"New Elective Course"`、`"Edit Elective Course"`、`"Course Name *"`、`"Subject"`、`"Grade"`、`"Capacity"`、`"Classroom"`、`"Schedule"`、`"Credit"`、`"Selection Mode"`、`"First Come First Served"`、`"Lottery"`、`"Start Date"`、`"End Date"`、`"Selection Start"`、`"Selection End"`、`"Description"`、`"Cancel"`、`"Create"`、`"Save"`、`"Saving..."`、`"My Selections"`、`"Available Courses"`、`"No selections yet"`、`"Browse available courses below..."`、`"No available courses"`、`"Drop"`、`"Drop this course?"`、`"You are about to drop..."`、`"Yes, drop course"`、`"Already selected"`、`"Select"`、`"Selecting..."`、`"Search by course name, teacher..."`、`"All Modes"`、`"Selection Mode"`([elective-course-list.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/elective-course-list.tsx)、[elective-course-form.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/elective-course-form.tsx)、[student-selection-view.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/student-selection-view.tsx)、[elective-filters.tsx](file:///e:/Desktop/CICD/src/modules/elective/components/elective-filters.tsx))
|
||||
- 状态标签常量硬编码英文:`ELECTIVE_STATUS_LABELS` / `SELECTION_MODE_LABELS` / `COURSE_SELECTION_STATUS_LABELS` 在 [types.ts#L69-L97](file:///e:/Desktop/CICD/src/modules/elective/types.ts#L69) 直接写死英文。
|
||||
- **违反规则**:同 2.2.1。
|
||||
- **后果**:同 2.2.1。
|
||||
|
||||
#### 问题 2.2.3 | i18n 翻译文件未注册新命名空间(P1)
|
||||
|
||||
- **位置**:[src/i18n/request.ts](file:///e:/Desktop/CICD/src/i18n/request.ts)
|
||||
- **现象**:`request.ts` 加载了 12 个命名空间(common/auth/onboarding/classes/errors/dashboard/examHomework/announcements/messages/settings/textbooks/grade),但**未加载 attendance/elective 命名空间**(这两个文件也不存在)。
|
||||
- **违反规则**:项目规则"所有用户可见文本必须适配 i18n"。
|
||||
- **后果**:即使组件层加了 `useTranslations("attendance")`,运行时也会因消息缺失而回退到 key 本身。
|
||||
|
||||
### 2.3 类型安全
|
||||
|
||||
#### 问题 2.3.1 | `as` 断言与 `as never` 类型逃逸(P1)
|
||||
|
||||
- **位置**:
|
||||
- [elective-course-form.tsx#L204](file:///e:/Desktop/CICD/src/modules/elective/components/elective-course-form.tsx#L204):`setSelectionMode(v as "fcfs" | "lottery")` —— `v` 已是 `string`,应用类型守卫或 `ElectiveSelectionModeEnum` 校验。
|
||||
- [elective-course-list.tsx#L54](file:///e:/Desktop/CICD/src/modules/elective/components/elective-course-list.tsx#L54):`await action(null as never, formData)` —— 用 `as never` 绕过 `prevState` 类型检查,是类型逃逸。
|
||||
- [attendance-sheet.tsx#L126](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-sheet.tsx#L126):`{} as Record<AttendanceStatus, number>` —— 空对象断言为完整 Record,运行时 `statusCounts[status]` 在未初始化时会 `undefined`。
|
||||
- **违反规则**:项目规则"禁止 `as` 断言(除非从 `unknown` 转换或测试中,需注释原因)"。
|
||||
- **后果**:类型系统无法保护运行时错误;`as never` 让编译器失去对 `prevState` 的校验。
|
||||
|
||||
#### 问题 2.3.2 | `attendance-sheet.tsx` 使用 `window.confirm` 阻塞 UI(P2)
|
||||
|
||||
- **位置**:[attendance-sheet.tsx#L107](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-sheet.tsx#L107):`if (!window.confirm("当前班级有未保存的考勤记录,确认切换班级?"))`
|
||||
- **现象**:使用浏览器原生 `confirm`,与模块内其他删除操作使用的 `AlertDialog`/`Dialog` 不一致。
|
||||
- **违反规则**:项目规则"组合优先"与 UI 一致性;`confirm()` 阻塞主线程且不可定制样式。
|
||||
- **后果**:交互体验割裂;移动端 `confirm` 表现不一;i18n 文案无法替换。
|
||||
|
||||
#### 问题 2.3.3 | `getAttendanceStats` 实现低效且类型不精确(P2)
|
||||
|
||||
- **位置**:[data-access.ts#L285-L308](file:///e:/Desktop/CICD/src/modules/attendance/data-access.ts#L285)
|
||||
- **现象**:`getAttendanceStats` 注释写"简化实现:基于已有查询统计",实际是先调 `getAttendanceRecords`(默认 pageSize=20)取前 20 条,再 `filter` 统计——**统计结果只基于前 20 条记录**,不是全量。
|
||||
- **违反规则**:项目规则"函数返回值必须显式标注"(此处已标注,但语义错误)。
|
||||
- **后果**:管理员考勤总览页的 6 卡片统计**永远是前 20 条记录的统计**,不是全校考勤统计,数据严重失真。
|
||||
|
||||
#### 问题 2.3.4 | `getClassStudentsForAttendance` 直查 `classEnrollments`(P1)
|
||||
|
||||
- **位置**:[data-access.ts#L208-L219](file:///e:/Desktop/CICD/src/modules/attendance/data-access.ts#L208)
|
||||
- **现象**:架构图 §2.10 标注"✅ P1-1 已修复:~~`getClassStudentsForAttendance` 直查 `classEnrollments`~~ 改为通过 classes data-access 获取",但**实际代码仍直接查询 `classEnrollments` 表**(`db.select(...).from(classEnrollments).innerJoin(users, ...)`)。
|
||||
- **违反规则**:项目规则"模块间只能通过对方 data-access 通信,禁止跨模块直接查询数据库表"。架构图记录与实际代码不一致。
|
||||
- **原因**:架构图记录错误,或修复后被回退。
|
||||
- **后果**:classes 模块修改 `classEnrollments` schema 会破坏 attendance 模块;架构图可信度受损。
|
||||
|
||||
### 2.4 错误与边界处理
|
||||
|
||||
#### 问题 2.4.1 | 完全缺失 React Error Boundary(P0)
|
||||
|
||||
- **位置**:
|
||||
- 考勤:`src/app/(dashboard)/admin/attendance/`、`src/app/(dashboard)/teacher/attendance/`、`src/app/(dashboard)/student/attendance/`、`src/app/(dashboard)/parent/attendance/` 均无 `error.tsx`
|
||||
- 选修课:`src/app/(dashboard)/admin/elective/`、`src/app/(dashboard)/teacher/elective/`、`src/app/(dashboard)/student/elective/` 均无 `error.tsx`
|
||||
- **现象**:7 个页面目录均无错误边界,DB 查询失败、Server Action 抛错时整页白屏。
|
||||
- **违反规则**:项目规则"每个独立的数据区块必须用 React Error Boundary 包裹"。
|
||||
- **后果**:一次 DB 抖动导致整个考勤/选修课页面崩溃,无法隔离故障域;用户只能手动刷新。
|
||||
|
||||
#### 问题 2.4.2 | 骨架屏覆盖不全(P2)
|
||||
|
||||
- **位置**:
|
||||
- 考勤:仅 `student/attendance/loading.tsx`、`parent/attendance/loading.tsx` 存在;`admin/attendance/`、`teacher/attendance/`、`teacher/attendance/sheet/`、`teacher/attendance/stats/` 均无骨架屏。
|
||||
- 选修课:仅 `student/elective/loading.tsx` 存在;`admin/elective/`、`admin/elective/create/`、`admin/elective/[id]/edit/`、`teacher/elective/` 均无骨架屏。
|
||||
- **违反规则**:项目规则"异步数据使用 React Suspense + 骨架屏"。
|
||||
- **后果**:管理员/教师端首屏白屏时间长,体验差。
|
||||
|
||||
#### 问题 2.4.3 | 空状态文案与组件不统一(P2)
|
||||
|
||||
- **位置**:
|
||||
- [attendance-record-list.tsx#L54-L60](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-record-list.tsx#L54):内联 `<div>No attendance records found.</div>`
|
||||
- [attendance-sheet.tsx#L245-L248](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-sheet.tsx#L245):内联 `<p>No students in this class...</p>`
|
||||
- 列表页则用 `EmptyState` 组件
|
||||
- **后果**:同一模块内空状态有两种写法,维护成本高,a11y 属性缺失。
|
||||
|
||||
#### 问题 2.4.4 | Server Action 错误消息英文硬编码(P2)
|
||||
|
||||
- **位置**:
|
||||
- [attendance/actions.ts#L56](file:///e:/Desktop/CICD/src/modules/attendance/actions.ts#L56):`"Attendance recorded"`、`"Invalid form data"`、`"Unexpected error"`
|
||||
- [elective/actions.ts#L88](file:///e:/Desktop/CICD/src/modules/elective/actions.ts#L88):`"Elective course created"`、`"Course not found"`、`"Invalid form data"`
|
||||
- **现象**:所有 Action 的 `message` 字段硬编码英文,未走 i18n。
|
||||
- **违反规则**:项目规则"所有用户可见文本必须适配 i18n"。
|
||||
- **后果**:toast 提示无法本地化。
|
||||
|
||||
### 2.5 组件复用与组合
|
||||
|
||||
#### 问题 2.5.1 | 考勤状态标签/颜色常量重复定义(P1)
|
||||
|
||||
- **位置**:
|
||||
- [attendance/types.ts#L86-L103](file:///e:/Desktop/CICD/src/modules/attendance/types.ts#L86):`ATTENDANCE_STATUS_LABELS` / `ATTENDANCE_STATUS_COLORS`
|
||||
- [parent-attendance-calendar.tsx#L14-L28](file:///e:/Desktop/CICD/src/modules/parent/components/parent-attendance-calendar.tsx#L14):`STATUS_DOT` / `STATUS_LABEL`(与 attendance 重复)
|
||||
- [attendance-sheet.tsx#L39-L61](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-sheet.tsx#L39):`STATUS_OPTIONS` / `STATUS_SHORTCUTS` / `STATUS_STYLES`(部分重复)
|
||||
- [attendance-filters.tsx#L21-L27](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-filters.tsx#L21):`STATUS_OPTIONS`(与 sheet 重复)
|
||||
- **现象**:考勤状态枚举的标签、颜色、快捷键、样式在 4 个文件里各写一份。
|
||||
- **违反规则**:项目规则"最大化复用……抽象为泛型组件和 hooks"。
|
||||
- **后果**:新增状态需改 4 处;当前已出现不一致(`ATTENDANCE_STATUS_COLORS` 用 `"outline"` 表示 early_leave,但 `STATUS_STYLES` 用 `bg-blue-500`)。
|
||||
|
||||
#### 问题 2.5.2 | 选修课状态标签/颜色常量分散(P1)
|
||||
|
||||
- **位置**:
|
||||
- [elective/types.ts#L69-L108](file:///e:/Desktop/CICD/src/modules/elective/types.ts#L69):4 组常量(`ELECTIVE_STATUS_LABELS` / `ELECTIVE_STATUS_COLORS` / `SELECTION_MODE_LABELS` / `COURSE_SELECTION_STATUS_LABELS` / `COURSE_SELECTION_STATUS_COLORS`)
|
||||
- [elective-course-form.tsx#L208-L213](file:///e:/Desktop/CICD/src/modules/elective/components/elective-course-form.tsx#L208):Select 选项硬编码 `"First Come First Served"` / `"Lottery"`(未复用 `SELECTION_MODE_LABELS`)
|
||||
- [elective-filters.tsx#L40-L44](file:///e:/Desktop/CICD/src/modules/elective/components/elective-filters.tsx#L40):Select 选项硬编码(同上)
|
||||
- **现象**:状态标签在 types.ts 集中定义,但表单/筛选组件未复用,重新硬编码。
|
||||
- **后果**:标签变更需改 3 处;i18n 改造时需同步多处。
|
||||
|
||||
#### 问题 2.5.3 | 考勤页面布局重复(P2)
|
||||
|
||||
- **位置**:
|
||||
- [admin/attendance/page.tsx#L62-L89](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/attendance/page.tsx#L62)
|
||||
- [teacher/attendance/page.tsx#L63-L114](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/attendance/page.tsx#L63)
|
||||
- **现象**:两个页面的标题区 + 筛选区 + 列表区结构几乎相同,仅按钮和分页略有差异。
|
||||
- **违反规则**:项目规则"最大化复用"。
|
||||
- **后果**:UI 调整需改多处。
|
||||
|
||||
#### 问题 2.5.4 | 选修课列表页布局重复(P2)
|
||||
|
||||
- **位置**:
|
||||
- [admin/elective/page.tsx#L30-L45](file:///e:/Desktop/CICD/src/app/(dashboard)/admin/elective/page.tsx#L30)
|
||||
- [teacher/elective/page.tsx#L37-L52](file:///e:/Desktop/CICD/src/app/(dashboard)/teacher/elective/page.tsx#L37)
|
||||
- **现象**:admin 和 teacher 列表页结构完全相同,仅 `createHref` 不同。
|
||||
- **后果**:同 2.5.3。
|
||||
|
||||
### 2.6 可访问性(a11y)
|
||||
|
||||
#### 问题 2.6.1 | 考勤点名表单缺 aria-label(P2)
|
||||
|
||||
- **位置**:[attendance-sheet.tsx#L215-L226](file:///e:/Desktop/CICD/src/modules/attendance/components/attendance-sheet.tsx#L215)
|
||||
- **现象**:班级选择器 `<Select>` 无 `aria-label`,日期输入框有 `id="date"` 但无 `aria-label`;状态按钮组有 `aria-pressed` 和 `aria-label`(✅ 良好),但表格行 `<TableRow>` 缺 `role="button"` 与 `tabIndex`。
|
||||
- **违反规则**:项目规则"可访问性(a11y):语义化标签、ARIA 属性、键盘导航"。
|
||||
- **后果**:屏幕阅读器用户无法理解筛选区用途。
|
||||
|
||||
#### 问题 2.6.2 | 选修课卡片缺语义化标签(P2)
|
||||
|
||||
- **位置**:[elective-course-list.tsx#L110-L227](file:///e:/Desktop/CICD/src/modules/elective/components/elective-course-list.tsx#L110)
|
||||
- **现象**:课程卡片用 `<Card>` 但无 `role="article"` 或 `aria-label`;"Open"/"Close"/"Lottery"/"Delete" 按钮有图标但 `aria-label` 缺失(仅有 `variant` 文本)。
|
||||
- **后果**:屏幕阅读器用户无法快速定位卡片内容。
|
||||
|
||||
#### 问题 2.6.3 | 考勤月历键盘导航缺失(P2)
|
||||
|
||||
- **位置**:[parent-attendance-calendar.tsx#L143-L177](file:///e:/Desktop/CICD/src/modules/parent/components/parent-attendance-calendar.tsx#L143)
|
||||
- **现象**:月历日期格子用 `<div>`,无 `tabIndex`、无方向键导航;月份切换按钮有 `aria-label`(✅ 良好),但日期格子不可聚焦。
|
||||
- **后果**:键盘用户无法浏览具体日期的考勤状态。
|
||||
|
||||
### 2.7 可测试性
|
||||
|
||||
#### 问题 2.7.1 | 纯逻辑未导出,无法单测(P1)
|
||||
|
||||
- **位置**:
|
||||
- [attendance/data-access-stats.ts#L26-L39](file:///e:/Desktop/CICD/src/modules/attendance/data-access-stats.ts#L26) `computeStats`(模块内未导出)
|
||||
- [parent-attendance-warning.tsx#L14-L55](file:///e:/Desktop/CICD/src/modules/parent/components/parent-attendance-warning.tsx#L14) `buildWarnings`(模块内未导出)
|
||||
- [parent-attendance-rate-card.tsx#L14-L30](file:///e:/Desktop/CICD/src/modules/parent/components/parent-attendance-rate-card.tsx#L14) `aggregate` / `rateTone`(模块内未导出)
|
||||
- [parent-attendance-calendar.tsx#L30-L62](file:///e:/Desktop/CICD/src/modules/parent/components/parent-attendance-calendar.tsx#L30) `formatDateKey` / `parseDateKey` / `buildCalendarDays` / `isSameDay`(模块内未导出)
|
||||
- [elective/data-access-operations.ts#L14-L19](file:///e:/Desktop/CICD/src/modules/elective/data-access-operations.ts#L14) `buildLotteryRankCase`(模块内未导出)
|
||||
- **现象**:这些纯函数(统计计算、预警规则、聚合、日期工具、SQL 构造)是核心逻辑,但未导出,无法写单测;两个模块目录下无任何 `__tests__` 或 `*.test.ts`。
|
||||
- **违反规则**:项目规则"数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks,与 UI 分离;导出清晰的接口类型以便 mock"。
|
||||
- **后果**:考勤统计、预警阈值、抽签算法这类容易出 bug 的逻辑无回归保护。
|
||||
|
||||
#### 问题 2.7.2 | 零测试覆盖(P1)
|
||||
|
||||
- **位置**:两个模块整体
|
||||
- **现象**:无单元测试、无集成测试、无 e2e 测试。
|
||||
- **后果**:重构高风险。
|
||||
|
||||
### 2.8 性能
|
||||
|
||||
#### 问题 2.8.1 | `getAttendanceStats` 全表扫描但只统计前 20 条(P0)
|
||||
|
||||
- **位置**:[data-access.ts#L285-L308](file:///e:/Desktop/CICD/src/modules/attendance/data-access.ts#L285)
|
||||
- **现象**:见 2.3.3。`getAttendanceRecords` 默认 `pageSize=20`,`getAttendanceStats` 调用它后只统计 `items`(20 条),但管理员总览页展示的是"全校考勤统计"——**数据严重失真**。
|
||||
- **后果**:管理员看到的出勤率永远是前 20 条记录的出勤率,决策失误。
|
||||
|
||||
#### 问题 2.8.2 | `getStudentAttendanceSummary` 一次拉全量记录(P2)
|
||||
|
||||
- **位置**:[data-access-stats.ts#L60-L68](file:///e:/Desktop/CICD/src/modules/attendance/data-access-stats.ts#L60)
|
||||
- **现象**:学生汇总页一次性加载该学生所有考勤记录(无分页),仅 `recentRecords` 截取前 20 条,但 `stats` 基于全量。
|
||||
- **后果**:考勤记录多的学生首屏慢。
|
||||
|
||||
#### 问题 2.8.3 | `resolveCourseDisplayNames` 每次调用都全量拉取科目/年级/教师(P2)
|
||||
|
||||
- **位置**:[elective/data-access.ts#L100-L122](file:///e:/Desktop/CICD/src/modules/elective/data-access.ts#L100)
|
||||
- **现象**:每次查询课程列表都调用 `getSubjectOptions()` / `getGradeOptions()` / `getUserNamesByIds()`,无缓存(虽然 `getElectiveCourses` 用了 `cache()`,但内部 `resolveCourseDisplayNames` 仍会执行)。
|
||||
- **后果**:高频访问时重复查询。
|
||||
|
||||
### 2.9 安全性
|
||||
|
||||
#### 问题 2.9.1 | Server Action 未校验资源归属(P0)
|
||||
|
||||
- **位置**:
|
||||
- [attendance/actions.ts#L98-L128](file:///e:/Desktop/CICD/src/modules/attendance/actions.ts#L98) `updateAttendanceAction(id, ...)`:仅校验 `ATTENDANCE_MANAGE` 权限,未校验 `id` 对应的考勤记录是否属于当前教师所教班级。
|
||||
- [attendance/actions.ts#L130-L143](file:///e:/Desktop/CICD/src/modules/attendance/actions.ts#L130) `deleteAttendanceAction(id)`:同上。
|
||||
- [elective/actions.ts#L94-L134](file:///e:/Desktop/CICD/src/modules/elective/actions.ts#L94) `updateElectiveCourseAction(id, ...)`:仅校验 `ELECTIVE_MANAGE`,未校验 `id` 对应课程是否属于当前教师(admin 可改全部,teacher 应只能改自己的课程)。
|
||||
- [elective/actions.ts#L136-L153](file:///e:/Desktop/CICD/src/modules/elective/actions.ts#L136) `deleteElectiveCourseAction`:同上。
|
||||
- **违反规则**:项目规则"Server Action 二次校验"、"所有敏感数据查询必须在 data-access 层结合当前用户权限过滤"。
|
||||
- **后果**:教师 A 可通过改 `id` 篡改/删除教师 B 的考勤记录或选修课(越权写)。
|
||||
|
||||
#### 问题 2.9.2 | `getClassAttendanceForDateAction` 未校验班级归属(P1)
|
||||
|
||||
- **位置**:[attendance/actions.ts#L212-L225](file:///e:/Desktop/CICD/src/modules/attendance/actions.ts#L212)
|
||||
- **现象**:仅校验 `ATTENDANCE_READ`,未校验 `classId` 是否属于当前教师所教班级。
|
||||
- **后果**:教师可查看任意班级的考勤明细。
|
||||
|
||||
#### 问题 2.9.3 | `saveAttendanceRulesAction` 未校验班级归属(P1)
|
||||
|
||||
- **位置**:[attendance/actions.ts#L227-L257](file:///e:/Desktop/CICD/src/modules/attendance/actions.ts#L227)
|
||||
- **现象**:仅校验 `ATTENDANCE_MANAGE`,未校验 `classId` 是否属于当前教师所教班级。
|
||||
- **后果**:教师可修改任意班级的考勤规则。
|
||||
|
||||
#### 问题 2.9.4 | `runLotteryAction` / `openSelectionAction` / `closeSelectionAction` 未校验课程归属(P1)
|
||||
|
||||
- **位置**:[elective/actions.ts#L155-L211](file:///e:/Desktop/CICD/src/modules/elective/actions.ts#L155)
|
||||
- **现象**:仅校验 `ELECTIVE_MANAGE`,未校验 `courseId` 是否属于当前教师。
|
||||
- **后果**:教师可对他人课程执行抽签/开放/关闭。
|
||||
|
||||
### 2.10 监控与埋点
|
||||
|
||||
#### 问题 2.10.1 | 关键操作无埋点接口(P2)
|
||||
|
||||
- **位置**:两个模块全部 Action
|
||||
- **现象**:考勤录入、选课、抽签这类关键操作无任何埋点钩子。
|
||||
- **违反规则**:项目规则"监控:方案中预留关键操作埋点接口"。
|
||||
- **后果**:无法统计考勤录入率、选课转化率、抽签冲突率等业务指标。
|
||||
|
||||
---
|
||||
|
||||
## 三、行业差距对比
|
||||
|
||||
对标国内外主流 K12 教育平台(如校宝在线、ClassIn、Seewo、PowerSchool、Veracross、Khan Academy)在考勤与选修课模块的设计,本模块存在以下差距:
|
||||
|
||||
### 3.1 考勤模块
|
||||
|
||||
| 行业优秀实践 | 本模块现状 | 影响 |
|
||||
|---|---|---|
|
||||
| 多维度考勤:按课节/全天/活动考勤 | 仅按"班级+日期"考勤,无课节维度 | 无法支撑"上午缺勤/下午缺勤"细分,K12 排课制场景受限 |
|
||||
| 自动考勤:对接校园卡/人脸/蓝牙签到 | 仅手动点名 | 教师负担重,数据滞后 |
|
||||
| 考勤异常自动通知家长(SMS/微信/站内信) | 仅家长端被动查看 | 家长无法及时获知孩子缺勤 |
|
||||
| 考勤趋势图表(按周/月/学期) | 仅静态统计卡片 | 无法发现出勤规律(如每周五缺勤多) |
|
||||
| 考勤预警规则可配置(连续缺勤 N 次触发) | 仅 `attendanceRules` 表存阈值,无触发逻辑 | 规则形同虚设 |
|
||||
| 请假申请流程(学生/家长发起→教师审批→自动标记 excused) | 无请假流程,`excused` 状态需手动录入 | 请销假流程断裂 |
|
||||
| 补签/改签审计日志 | 无审计 | 无法追溯考勤篡改 |
|
||||
| 班级出勤热力图(哪天缺勤多) | 无 | 教师无法快速定位异常日 |
|
||||
|
||||
### 3.2 选修课模块
|
||||
|
||||
| 行业优秀实践 | 本模块现状 | 影响 |
|
||||
|---|---|---|
|
||||
| 课程目录:分类/标签/搜索/筛选/排序 | 仅按状态/模式筛选,无分类标签 | 学生发现课程困难 |
|
||||
| 课程详情页:大纲/教师介绍/评价/历史选课数据 | 仅卡片展示基本信息 | 学生决策信息不足 |
|
||||
| 选课优先级多志愿(第一志愿/第二志愿)+ 智能分配 | `priority` 字段存在但抽签仅按 priority 升序,无多志愿匹配算法 | 抽签结果可能让学生一无所获 |
|
||||
| 候补队列实时通知(有人退课自动递补+通知) | FCFS 模式有递补逻辑但无通知 | 候补学生不知道自己被录取 |
|
||||
| 选课时间窗口冲突检测(与必修课/其他选修课冲突) | 无 | 学生可能选到时间冲突的课程 |
|
||||
| 学分上限/下限校验 | 无 | 学生可能选课过多或过少 |
|
||||
| 教师端:选课名单管理/成绩录入/导出 | 教师端仅列表,无名单/成绩 | 教师无法管理已选学生 |
|
||||
| 课程评价/满意度调查 | 无 | 无法改进课程质量 |
|
||||
| 历史选课数据归档 | 无 | 无法分析选课趋势 |
|
||||
|
||||
### 3.3 多角色协作层
|
||||
|
||||
| 行业优秀实践 | 本模块现状 | 影响 |
|
||||
|---|---|---|
|
||||
| admin:考勤全校热力图 + 异常班级排名 + 选课数据大盘 | admin 考勤仅 6 卡片(且统计失真),选课无大盘 | 管理员无法宏观决策 |
|
||||
| teacher:考勤批量补签 + 选课名单导出 Excel | 考勤无补签,选课无导出 | 教师日常操作低效 |
|
||||
| parent:考勤异常推送 + 请假申请 + 选课结果通知 | parent 仅被动查看,无请假/通知 | 家长参与度低 |
|
||||
| student:考勤自查 + 请假申请 + 选课推荐 | student 仅查看,无请假/推荐 | 学生自主性差 |
|
||||
|
||||
### 3.4 交互体验层
|
||||
|
||||
| 行业优秀实践 | 本模块现状 | 影响 |
|
||||
|---|---|---|
|
||||
| 考勤点名:一键全到/批量按状态/键盘快捷键 | ✅ 已实现(快捷键 P/A/L/E/X) | 良好 |
|
||||
| 考勤点名:学生头像/学号排序/拼音搜索 | 仅按 name 排序,搜索按 name includes | 中文环境拼音搜索缺失 |
|
||||
| 选课:课程对比/收藏/愿望清单 | 无 | 学生难以比较课程 |
|
||||
| 选课:移动端优化(卡片瀑布流) | 响应式但未针对移动端优化 | 平板/手机体验一般 |
|
||||
| 空状态/加载骨架屏/错误重试 | 部分页面有骨架屏,错误边界完全缺失 | 体验不稳定 |
|
||||
|
||||
### 3.5 数据分析层
|
||||
|
||||
| 行业优秀实践 | 本模块现状 | 影响 |
|
||||
|---|---|---|
|
||||
| 考勤与成绩关联分析(缺勤多→成绩下降) | 无 | 无法预警学业风险 |
|
||||
| 选课与升学路径关联(选某课→升某专业) | 无 | 无法指导学生规划 |
|
||||
| 考勤/选课数据导出 Excel/PDF | 考勤无导出,选课无导出 | 无法离线分析 |
|
||||
|
||||
---
|
||||
|
||||
## 四、改进优先级建议
|
||||
|
||||
### P0(紧急,阻塞多角色上线或数据严重失真)
|
||||
|
||||
1. **修复 `getAttendanceStats` 统计失真**:改为基于 `COUNT` 聚合查询,而非取前 20 条 `items` 统计;或直接在 data-access 层用 `db.select({ count, status }).groupBy(status)` 一次查询。
|
||||
2. **修复 `getClassStudentsForAttendance` 跨模块直查**:改为调用 `classes/data-access.getActiveStudentIdsByClassId` 或新增 `classes/data-access.getClassStudentsForAttendance`,与架构图记录一致。
|
||||
3. **Server Action 资源归属校验**:在 `updateAttendanceAction` / `deleteAttendanceAction` / `updateElectiveCourseAction` / `deleteElectiveCourseAction` / `runLotteryAction` / `openSelectionAction` / `closeSelectionAction` / `saveAttendanceRulesAction` / `getClassAttendanceForDateAction` 内,结合 `ctx.dataScope` 与 `ctx.userId` 校验资源归属(教师只能操作自己班级/课程)。
|
||||
4. **全模块 i18n 改造**:新增 `shared/i18n/messages/{en,zh-CN}/attendance.json` 与 `elective.json` 命名空间,在 `i18n/request.ts` 注册加载;提取所有硬编码文案;状态标签常量改为 i18n key(运行时通过 `useTranslations` 解析)。
|
||||
5. **补齐 Error Boundary**:在 7 个页面目录下新增 `error.tsx`(admin/teacher/student/parent × attendance/elective),复用现有 `EmptyState` + `AlertCircle` 模式。
|
||||
|
||||
### P1(重要,影响正确性与可维护性)
|
||||
|
||||
1. **解耦 parent 模块对 attendance 类型的直接依赖**:在 parent 模块定义视图模型接口(`ParentAttendanceSummary`),由 `parent/attendance/page.tsx` 在 RSC 层做映射;或抽取共享类型到 `shared/types/attendance.ts`。
|
||||
2. **消除状态常量重复**:新建 `attendance/constants.ts` 集中导出 `ATTENDANCE_STATUS_OPTIONS`(含 value/label-key/color/shortcut/icon),供 sheet/filters/stats/calendar 复用;elective 同理。
|
||||
3. **抽取纯函数并补单测**:导出 `computeStats` / `buildWarnings` / `aggregate` / `rateTone` / `formatDateKey` / `parseDateKey` / `buildCalendarDays` / `isSameDay` / `buildLotteryRankCase`,补 Vitest 单测覆盖空数组、边界值、闰年、跨月等。
|
||||
4. **修复类型断言**:用类型守卫替换 `as "fcfs" | "lottery"`(用 `ElectiveSelectionModeEnum.safeParse`);用 `Object.fromEntries(STATUS_OPTIONS.map(s => [s, 0]))` 替换 `{} as Record<...>`;删除 `as never`,改为泛型约束 `prevState`。
|
||||
5. **统一 `window.confirm` 为 `AlertDialog`**:`attendance-sheet.tsx` 的切换班级确认改为 `AlertDialog`,与模块其他删除操作一致。
|
||||
6. **补齐骨架屏**:为 admin/teacher 考勤与选修课页面补 `loading.tsx`。
|
||||
7. **统一空状态**:内联空状态全部改用 `EmptyState` 组件。
|
||||
8. **a11y 改进**:考勤点名表单补 `aria-label`;选修课卡片补 `role="article"` + `aria-label`;考勤月历日期格子补 `tabIndex` + 方向键导航。
|
||||
9. **清理死代码 Action**:删除无调用方的 6 个读 Action(`getAttendanceAction` / `getStudentAttendanceAction` / `getClassAttendanceStatsAction` / `getClassAttendanceForDateAction` / `getAttendanceRulesAction` / `getElectiveCoursesAction` / `getStudentSelectionsAction` / `getAvailableCoursesAction`),或改为页面层调用(统一权限二次校验)。
|
||||
10. **埋点接口预留**:在 `data-access` 与 `actions` 中预留 `onAttendanceRecorded` / `onCourseSelected` / `onLotteryCompleted` 钩子,供后续接入监控。
|
||||
|
||||
### P2(优化,提升体验与专业度)
|
||||
|
||||
1. **页面布局复用**:抽取 `AttendancePageLayout` / `ElectivePageLayout` 组件,admin/teacher 页面复用。
|
||||
2. **考勤统计图表**:接入 recharts,按周/月展示出勤趋势线、缺勤热力图。
|
||||
3. **选修课课程详情页**:新增 `/student/elective/[id]` 详情页,展示大纲/教师/评价。
|
||||
4. **选课时间冲突检测**:在 `selectCourse` 内校验学生已有选课的 schedule 是否冲突。
|
||||
5. **学分上限校验**:在 `selectCourse` 内校验学生本学期已选学分 + 当前课程学分是否超过上限。
|
||||
6. **考勤/选课数据导出**:复用 `shared/lib/excel.ts`,新增导出 Action。
|
||||
7. **移动端优化**:选修课卡片改为瀑布流,考勤点名表单窄屏优化。
|
||||
8. **补全架构图同步**(见第五节)。
|
||||
|
||||
---
|
||||
|
||||
## 五、架构图同步说明
|
||||
|
||||
本次审计发现 [004_architecture_impact_map.md](file:///e:/Desktop/CICD/docs/architecture/004_architecture_impact_map.md) §2.10(attendance)与 §2.20(elective)以及 [005_architecture_data.json](file:///e:/Desktop/CICD/docs/architecture/005_architecture_data.json) 中对应节点存在以下偏差,需同步修正:
|
||||
|
||||
### 5.1 attendance 行数与组件统计偏差
|
||||
|
||||
| 项 | 图记 | 实际 |
|
||||
|------|------|------|
|
||||
| `actions.ts` 行数 | 271 | 271(一致) |
|
||||
| `data-access.ts` 行数 | 309 | 309(一致) |
|
||||
| `data-access-stats.ts` 行数 | 145 | 145(一致) |
|
||||
| 组件文件数 | 5(仅列 `AttendanceStatsCards`) | 8(`AttendanceSheet` / `AttendanceRecordList` / `AttendanceFilters` / `AttendanceStatsCard` / `AttendanceStatsCards` / `AttendanceStatsClassSelector` / `AttendanceRulesForm` / `StudentAttendanceView`) |
|
||||
| Actions 名称 | `getAttendanceRecordsAction` / `createAttendanceRecordAction` / `updateAttendanceRecordAction` / `deleteAttendanceRecordAction` / `getStudentAttendanceAction` / `getAttendanceStatsAction` | `recordAttendanceAction` / `batchRecordAttendanceAction` / `updateAttendanceAction` / `deleteAttendanceAction` / `getAttendanceAction` / `getStudentAttendanceAction` / `getClassAttendanceStatsAction` / `getClassAttendanceForDateAction` / `saveAttendanceRulesAction` / `getAttendanceRulesAction`(10 个) |
|
||||
|
||||
### 5.2 attendance 已知问题记录偏差
|
||||
|
||||
架构图 §2.10 标注"✅ P1-1 已修复:~~`getClassStudentsForAttendance` 直查 `classEnrollments`~~ 改为通过 classes data-access 获取",但**实际代码仍直接查询 `classEnrollments` 表**([data-access.ts#L208-L219](file:///e:/Desktop/CICD/src/modules/attendance/data-access.ts#L208))。需将架构图改为"❌ P1-1 未修复:`getClassStudentsForAttendance` 仍直查 `classEnrollments`"。
|
||||
|
||||
### 5.3 attendance 缺失功能记录
|
||||
|
||||
架构图未记录以下已实现的功能:
|
||||
- `attendanceRules` 表的 CRUD(`saveAttendanceRulesAction` / `getAttendanceRulesAction` + `upsertAttendanceRules` / `getAttendanceRules`)
|
||||
- `AttendanceRulesForm` 组件
|
||||
- `AttendanceRecordList` 组件(含删除对话框)
|
||||
- `StudentAttendanceView` 组件(学生/家长视图)
|
||||
- `AttendanceStatsClassSelector` 组件(ChipNav 筛选)
|
||||
|
||||
### 5.4 elective 行数与组件统计偏差
|
||||
|
||||
| 项 | 图记 | 实际 |
|
||||
|------|------|------|
|
||||
| `actions.ts` 行数 | 304 | 304(一致) |
|
||||
| `data-access.ts` 行数 | 250 | 250(一致) |
|
||||
| `data-access-operations.ts` 行数 | 245 | 245(一致) |
|
||||
| `data-access-selections.ts` 行数 | 189 | 149(减少 40 行) |
|
||||
| 组件文件数 | 3 | 4(缺 `student-selection-view.tsx`) |
|
||||
|
||||
### 5.5 elective usedBy 信息缺失
|
||||
|
||||
`getStudentSelectionsAction` / `getAvailableCoursesAction` 的 `usedBy` 字段标注为"待扩展",实际已被 `student/elective/page.tsx` 通过 data-access 直接调用(绕过 Action)。应改为"无调用方(页面层直接调 data-access)"或删除这两个 Action。
|
||||
|
||||
### 5.6 parent 跨模块 UI 依赖未记录
|
||||
|
||||
架构图 §2.19(parent)的依赖关系未标注 parent 模块对 attendance 模块类型的直接 import:
|
||||
- `parent/components/parent-attendance-warning.tsx` → `@/modules/attendance/types`
|
||||
- `parent/components/parent-attendance-rate-card.tsx` → `@/modules/attendance/types`
|
||||
- `parent/components/parent-attendance-calendar.tsx` → `@/modules/attendance/types`
|
||||
|
||||
应在 004 的 parent 依赖关系与 005 的 `dependencyMatrix` 中补充该 UI 层依赖,并标注为"待解耦(P1)"。
|
||||
|
||||
### 5.7 建议的 JSON 节点更新
|
||||
|
||||
`005_architecture_data.json` 中 `modules.attendance` 与 `modules.elective` 节点建议补充/修正:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"attendance": {
|
||||
"exports": {
|
||||
"actions": [
|
||||
"recordAttendanceAction", "batchRecordAttendanceAction",
|
||||
"updateAttendanceAction", "deleteAttendanceAction",
|
||||
"getAttendanceAction", "getStudentAttendanceAction",
|
||||
"getClassAttendanceStatsAction", "getClassAttendanceForDateAction",
|
||||
"saveAttendanceRulesAction", "getAttendanceRulesAction"
|
||||
],
|
||||
"dataAccess": [
|
||||
"getAttendanceRecords", "getClassAttendanceForDate",
|
||||
"createAttendanceRecord", "batchCreateAttendanceRecords",
|
||||
"updateAttendanceRecord", "deleteAttendanceRecord",
|
||||
"getClassStudentsForAttendance", // ❌ 仍直查 classEnrollments
|
||||
"getAttendanceRules", "upsertAttendanceRules",
|
||||
"getStudentAttendanceSummary", "getClassAttendanceStats",
|
||||
"getAttendanceStats" // ❌ 统计失真,仅基于前 20 条
|
||||
],
|
||||
"components": [
|
||||
"AttendanceSheet", "AttendanceRecordList", "AttendanceFilters",
|
||||
"AttendanceStatsCard", "AttendanceStatsCards",
|
||||
"AttendanceStatsClassSelector", "AttendanceRulesForm",
|
||||
"StudentAttendanceView"
|
||||
]
|
||||
},
|
||||
"knownIssues": [
|
||||
"getClassStudentsForAttendance 仍直查 classEnrollments(P1)",
|
||||
"getAttendanceStats 统计失真,仅基于前 20 条(P0)",
|
||||
"Server Action 未校验资源归属(P0)",
|
||||
"全模块零 i18n(P0)",
|
||||
"缺 Error Boundary(P0)",
|
||||
"parent 模块跨模块 import attendance 类型(P1)",
|
||||
"状态常量重复定义(P1)",
|
||||
"纯逻辑未导出,零单测(P1)"
|
||||
]
|
||||
},
|
||||
"elective": {
|
||||
"exports": {
|
||||
"actions": [
|
||||
"createElectiveCourseAction", "updateElectiveCourseAction",
|
||||
"deleteElectiveCourseAction", "openSelectionAction",
|
||||
"closeSelectionAction", "runLotteryAction",
|
||||
"selectCourseAction", "dropCourseAction",
|
||||
"getElectiveCoursesAction", // ❌ 无调用方
|
||||
"getStudentSelectionsAction", // ❌ 无调用方
|
||||
"getAvailableCoursesAction" // ❌ 无调用方
|
||||
],
|
||||
"components": [
|
||||
"ElectiveCourseList", "ElectiveCourseForm",
|
||||
"ElectiveFilters", "StudentSelectionView"
|
||||
]
|
||||
},
|
||||
"knownIssues": [
|
||||
"Server Action 未校验课程归属(P0)",
|
||||
"全模块零 i18n(P0)",
|
||||
"缺 Error Boundary(P0)",
|
||||
"3 个读 Action 无调用方(P1)",
|
||||
"状态常量分散,表单未复用(P1)",
|
||||
"纯逻辑未导出,零单测(P1)"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附:重构方案设计要点(不写实现代码)
|
||||
|
||||
为满足"完全解耦 / 组合优先 / 国际化就绪 / 最大化复用 / 错误与边界处理 / 可测试性 / 可扩展性 / 企业级补充"八项原则,建议按以下方向重构(详细实现留待后续任务):
|
||||
|
||||
### A. 数据服务接口抽象
|
||||
|
||||
```ts
|
||||
// attendance/services/types.ts
|
||||
export interface AttendanceDataService {
|
||||
listRecords(query: AttendanceQuery): Promise<PaginatedAttendanceResult>
|
||||
getStudentSummary(studentId: string, range?: DateRange): Promise<StudentAttendanceSummary | null>
|
||||
getClassStats(classId: string, range?: DateRange): Promise<ClassAttendanceSummary | null>
|
||||
getClassStudents(classId: string): Promise<Student[]>
|
||||
getRules(classId?: string): Promise<AttendanceRule[]>
|
||||
}
|
||||
|
||||
export interface AttendanceMutationService {
|
||||
record(input: RecordAttendanceInput): Promise<ActionState>
|
||||
batchRecord(input: BatchRecordAttendanceInput): Promise<ActionState>
|
||||
update(id: string, input: UpdateAttendanceInput): Promise<ActionState>
|
||||
delete(id: string): Promise<ActionState>
|
||||
saveRules(input: AttendanceRuleInput): Promise<ActionState>
|
||||
}
|
||||
```
|
||||
|
||||
通过 `AttendanceDataProvider`(React Context)注入不同角色实现:teacher 实现 = 按 `class_taught` scope 过滤 + 可写;student 实现 = 按 `owned` scope 过滤 + 只读;admin 实现 = 全量 + 可写;parent 实现 = 按 `children` scope 过滤 + 只读。
|
||||
|
||||
elective 模块同理定义 `ElectiveDataService` / `ElectiveMutationService`。
|
||||
|
||||
### B. 配置驱动角色渲染
|
||||
|
||||
```ts
|
||||
// attendance/config/role-config.ts
|
||||
export const ATTENDANCE_ROLE_CONFIG: Record<Role, AttendanceRoleConfig> = {
|
||||
admin: { widgets: ['stats', 'filters', 'list'], canManage: true, scope: 'all' },
|
||||
teacher: { widgets: ['stats', 'filters', 'list', 'sheet', 'rules'], canManage: true, scope: 'class_taught' },
|
||||
student: { widgets: ['summary'], canManage: false, scope: 'owned' },
|
||||
parent: { widgets: ['summary', 'calendar', 'warning', 'rateCard'], canManage: false, scope: 'children' },
|
||||
}
|
||||
```
|
||||
|
||||
页面根据 `useRoleConfig()` 决定渲染哪些 Widget,新增角色只改配置。
|
||||
|
||||
### C. 组合式 UI
|
||||
|
||||
- `AttendancePage` 改为 `children`-based 组合:`<AttendancePage><StatsCards /><Filters /><RecordList /></AttendancePage>`
|
||||
- parent 模块的考勤视图改为 render prop:`<ParentAttendanceView renderSummary={(summary) => <CustomCalendar summary={summary} />} />`,由页面层注入 calendar/warning/rateCard 组件,parent 模块内部不 import attendance 类型。
|
||||
|
||||
### D. i18n 翻译文件结构示例
|
||||
|
||||
```
|
||||
shared/i18n/messages/
|
||||
├─ en/attendance.json
|
||||
├─ en/elective.json
|
||||
├─ zh-CN/attendance.json
|
||||
└─ zh-CN/elective.json
|
||||
```
|
||||
|
||||
```jsonc
|
||||
// zh-CN/attendance.json
|
||||
{
|
||||
"title": { "admin": "考勤总览", "teacher": "考勤记录", "student": "我的考勤", "parent": "子女考勤" },
|
||||
"subtitle": { "admin": "查看全校所有班级的考勤记录", "teacher": "管理学生考勤记录" },
|
||||
"action": {
|
||||
"record": "录入考勤", "stats": "统计", "markAllPresent": "全部标记到场",
|
||||
"save": "保存", "cancel": "取消", "delete": "删除", "edit": "编辑"
|
||||
},
|
||||
"field": {
|
||||
"class": "班级", "date": "日期", "student": "学生", "status": "状态",
|
||||
"remark": "备注", "recordedBy": "记录人", "createdAt": "创建时间",
|
||||
"lateThreshold": "迟到阈值(分钟)", "earlyLeaveThreshold": "早退阈值(分钟)",
|
||||
"enableAutoMark": "启用自动标记(学生按时签到则自动标记到场)"
|
||||
},
|
||||
"status": {
|
||||
"present": "到场", "absent": "缺勤", "late": "迟到",
|
||||
"early_leave": "早退", "excused": "请假"
|
||||
},
|
||||
"stats": {
|
||||
"total": "总记录数", "present": "出勤", "absent": "缺勤",
|
||||
"late": "迟到", "earlyLeave": "早退", "excused": "请假",
|
||||
"presentRate": "出勤率", "lateRate": "迟到率"
|
||||
},
|
||||
"empty": {
|
||||
"noRecords": "暂无考勤记录", "noStudents": "该班级暂无学生",
|
||||
"noData": "暂无数据", "noClasses": "您还没有班级"
|
||||
},
|
||||
"dialog": {
|
||||
"deleteTitle": "删除考勤记录", "deleteDesc": "确定要删除这条考勤记录吗?此操作无法撤销。",
|
||||
"confirmSwitchClass": "当前班级有未保存的考勤记录,确认切换班级?"
|
||||
},
|
||||
"error": { "loadFailed": "考勤数据加载失败", "retry": "重试" }
|
||||
}
|
||||
```
|
||||
|
||||
```jsonc
|
||||
// zh-CN/elective.json
|
||||
{
|
||||
"title": { "admin": "选修课程", "teacher": "我的选修课", "student": "选课中心" },
|
||||
"subtitle": { "admin": "管理选修课程、开放/关闭选课与抽签" },
|
||||
"action": {
|
||||
"create": "新建课程", "edit": "编辑", "delete": "删除",
|
||||
"open": "开放选课", "close": "关闭选课", "lottery": "抽签",
|
||||
"select": "选择", "drop": "退课", "cancel": "取消", "save": "保存"
|
||||
},
|
||||
"field": {
|
||||
"name": "课程名称", "subject": "学科", "grade": "年级", "teacher": "教师",
|
||||
"capacity": "容量", "classroom": "教室", "schedule": "上课时间",
|
||||
"credit": "学分", "selectionMode": "选课模式",
|
||||
"startDate": "开始日期", "endDate": "结束日期",
|
||||
"selectionStart": "选课开始", "selectionEnd": "选课结束",
|
||||
"description": "课程简介"
|
||||
},
|
||||
"status": {
|
||||
"draft": "草稿", "open": "开放中", "closed": "已关闭", "cancelled": "已取消"
|
||||
},
|
||||
"selectionMode": { "fcfs": "先到先得", "lottery": "抽签" },
|
||||
"selectionStatus": {
|
||||
"selected": "已选", "enrolled": "已录取", "waitlist": "候补",
|
||||
"dropped": "已退课", "rejected": "未录取"
|
||||
},
|
||||
"section": { "mySelections": "我的选课", "available": "可选课程" },
|
||||
"empty": {
|
||||
"noCourses": "暂无选修课程", "noSelections": "暂无选课",
|
||||
"noAvailable": "暂无可选课程"
|
||||
},
|
||||
"dialog": {
|
||||
"dropTitle": "确认退课?", "dropDesc": "您即将退课 {course},此操作无法撤销,且若课程已满,您可能失去名额。",
|
||||
"confirmDrop": "确认退课"
|
||||
},
|
||||
"error": { "loadFailed": "选修课数据加载失败", "retry": "重试" }
|
||||
}
|
||||
```
|
||||
|
||||
### E. 错误边界与骨架屏
|
||||
|
||||
- 每个独立数据区块(统计卡片、筛选栏、记录列表、点名表单、规则表单、课程列表、选课视图)用 `<ErrorBoundary fallback={<ErrorState />}>` 包裹
|
||||
- 异步加载用 `<Suspense fallback={<AttendancePageSkeleton />}>`
|
||||
- 空状态、无权限、网络异常统一用 `EmptyState` / `ForbiddenState` / `ErrorState` 三套标准组件
|
||||
|
||||
### F. 可测试性
|
||||
|
||||
- 纯逻辑(`computeStats` / `buildWarnings` / `aggregate` / `rateTone` / `formatDateKey` / `parseDateKey` / `buildCalendarDays` / `isSameDay` / `buildLotteryRankCase`)抽到 `*/utils/` 并导出
|
||||
- 数据服务接口便于 mock,组件测试时注入 stub service
|
||||
- 补 Vitest 单测 + Playwright e2e(考勤点名、选课、抽签三条核心路径)
|
||||
|
||||
### G. 监控埋点
|
||||
|
||||
- 在 `data-access` 与 `actions` 中预留 `onAttendanceRecorded` / `onCourseSelected` / `onLotteryCompleted` / `onAttendanceRuleChanged` 钩子
|
||||
- 钩子默认 no-op,由后续监控模块通过 Context 注入实现
|
||||
24
src/app/(dashboard)/admin/attendance/error.tsx
Normal file
24
src/app/(dashboard)/admin/attendance/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function AdminAttendanceError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("attendance")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("errors.unexpected")}
|
||||
description={t("errors.unexpected")}
|
||||
action={{
|
||||
label: t("actions.save"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
src/app/(dashboard)/admin/elective/error.tsx
Normal file
24
src/app/(dashboard)/admin/elective/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function AdminElectiveError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("elective")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("errors.unexpected")}
|
||||
description={t("errors.unexpected")}
|
||||
action={{
|
||||
label: t("actions.save"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
src/app/(dashboard)/student/elective/error.tsx
Normal file
24
src/app/(dashboard)/student/elective/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function StudentElectiveError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("elective")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("errors.unexpected")}
|
||||
description={t("errors.unexpected")}
|
||||
action={{
|
||||
label: t("actions.save"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
src/app/(dashboard)/teacher/attendance/error.tsx
Normal file
24
src/app/(dashboard)/teacher/attendance/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function TeacherAttendanceError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("attendance")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("errors.unexpected")}
|
||||
description={t("errors.unexpected")}
|
||||
action={{
|
||||
label: t("actions.save"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { verifyTeacherOwnsClass } from "@/modules/classes/data-access"
|
||||
|
||||
import {
|
||||
RecordAttendanceSchema,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
batchCreateAttendanceRecords,
|
||||
updateAttendanceRecord,
|
||||
deleteAttendanceRecord,
|
||||
getAttendanceRecordClassId,
|
||||
getAttendanceRecords,
|
||||
getClassAttendanceForDate,
|
||||
getAttendanceRules,
|
||||
@@ -27,6 +29,27 @@ import {
|
||||
} from "./data-access-stats"
|
||||
import type { AttendanceQueryParams, AttendanceListItem } from "./types"
|
||||
|
||||
/**
|
||||
* 校验当前用户对考勤记录的归属权限。
|
||||
* - admin(scope=all):直接放行
|
||||
* - teacher(scope=class_taught):必须为记录所属班级的任课教师
|
||||
* - 其他 scope:拒绝(学生/家长不应调用写操作)
|
||||
*/
|
||||
async function assertRecordOwnership(
|
||||
recordId: string,
|
||||
ctx: Awaited<ReturnType<typeof requirePermission>>
|
||||
): Promise<{ ok: boolean; message?: string }> {
|
||||
if (ctx.dataScope.type === "all") return { ok: true }
|
||||
if (ctx.dataScope.type === "class_taught") {
|
||||
const classId = await getAttendanceRecordClassId(recordId)
|
||||
if (!classId) return { ok: false, message: "Attendance record not found" }
|
||||
const owns = await verifyTeacherOwnsClass(classId, ctx.userId)
|
||||
if (!owns) return { ok: false, message: "You do not own this attendance record" }
|
||||
return { ok: true }
|
||||
}
|
||||
return { ok: false, message: "Insufficient permissions" }
|
||||
}
|
||||
|
||||
export async function recordAttendanceAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
@@ -101,7 +124,12 @@ export async function updateAttendanceAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.ATTENDANCE_MANAGE)
|
||||
const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE)
|
||||
|
||||
const ownership = await assertRecordOwnership(id, ctx)
|
||||
if (!ownership.ok) {
|
||||
return { success: false, message: ownership.message ?? "Ownership check failed" }
|
||||
}
|
||||
|
||||
const parsed = UpdateAttendanceSchema.safeParse({
|
||||
status: formData.get("status") || undefined,
|
||||
@@ -131,7 +159,13 @@ export async function deleteAttendanceAction(
|
||||
id: string
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.ATTENDANCE_MANAGE)
|
||||
const ctx = await requirePermission(Permissions.ATTENDANCE_MANAGE)
|
||||
|
||||
const ownership = await assertRecordOwnership(id, ctx)
|
||||
if (!ownership.ok) {
|
||||
return { success: false, message: ownership.message ?? "Ownership check failed" }
|
||||
}
|
||||
|
||||
await deleteAttendanceRecord(id)
|
||||
revalidatePath("/teacher/attendance")
|
||||
return { success: true, message: "Attendance record deleted" }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Users, CheckCircle2, XCircle, Clock, LogOut, FileText } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
|
||||
@@ -15,44 +16,45 @@ interface AttendanceStatsCardsProps {
|
||||
}
|
||||
|
||||
export function AttendanceStatsCards({ stats }: AttendanceStatsCardsProps) {
|
||||
const t = useTranslations("attendance")
|
||||
const cards = [
|
||||
{
|
||||
title: "总记录数",
|
||||
title: t("stats.totalRecords"),
|
||||
value: stats.totalRecords,
|
||||
icon: FileText,
|
||||
color: "text-blue-500",
|
||||
bgColor: "bg-blue-500/10",
|
||||
},
|
||||
{
|
||||
title: "出勤",
|
||||
title: t("stats.present"),
|
||||
value: stats.presentCount,
|
||||
icon: CheckCircle2,
|
||||
color: "text-green-500",
|
||||
bgColor: "bg-green-500/10",
|
||||
},
|
||||
{
|
||||
title: "缺勤",
|
||||
title: t("stats.absent"),
|
||||
value: stats.absentCount,
|
||||
icon: XCircle,
|
||||
color: "text-red-500",
|
||||
bgColor: "bg-red-500/10",
|
||||
},
|
||||
{
|
||||
title: "迟到",
|
||||
title: t("stats.late"),
|
||||
value: stats.lateCount,
|
||||
icon: Clock,
|
||||
color: "text-yellow-500",
|
||||
bgColor: "bg-yellow-500/10",
|
||||
},
|
||||
{
|
||||
title: "早退",
|
||||
title: t("stats.earlyLeave"),
|
||||
value: stats.earlyLeaveCount,
|
||||
icon: LogOut,
|
||||
color: "text-orange-500",
|
||||
bgColor: "bg-orange-500/10",
|
||||
},
|
||||
{
|
||||
title: "出勤率",
|
||||
title: t("stats.attendanceRate"),
|
||||
value: `${stats.attendanceRate}%`,
|
||||
icon: Users,
|
||||
color: "text-primary",
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
attendanceRecords,
|
||||
attendanceRules,
|
||||
classes,
|
||||
classEnrollments,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
import { getClassActiveStudentsWithInfo } from "@/modules/classes/data-access"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
import type {
|
||||
@@ -205,17 +205,24 @@ export async function deleteAttendanceRecord(id: string): Promise<void> {
|
||||
await db.delete(attendanceRecords).where(eq(attendanceRecords.id, id))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取考勤记录的 classId(用于资源归属校验)。
|
||||
* 返回 null 表示记录不存在。
|
||||
*/
|
||||
export async function getAttendanceRecordClassId(id: string): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.select({ classId: attendanceRecords.classId })
|
||||
.from(attendanceRecords)
|
||||
.where(eq(attendanceRecords.id, id))
|
||||
.limit(1)
|
||||
return row?.classId ?? null
|
||||
}
|
||||
|
||||
export async function getClassStudentsForAttendance(
|
||||
classId: string
|
||||
): Promise<Array<{ id: string; name: string; email: string }>> {
|
||||
const rows = await db
|
||||
.select({ id: users.id, name: users.name, email: users.email })
|
||||
.from(classEnrollments)
|
||||
.innerJoin(users, eq(users.id, classEnrollments.studentId))
|
||||
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
|
||||
.orderBy(asc(users.name))
|
||||
|
||||
return rows.map((r) => ({ id: r.id, name: r.name ?? "Unknown", email: r.email }))
|
||||
// 通过 classes data-access 获取班级学生,避免跨模块直查 classEnrollments 表
|
||||
return getClassActiveStudentsWithInfo(classId)
|
||||
}
|
||||
|
||||
export async function getAttendanceRules(classId?: string): Promise<AttendanceRule[]> {
|
||||
@@ -288,15 +295,38 @@ export async function getAttendanceStats(params: {
|
||||
classId?: string
|
||||
date?: string
|
||||
}): Promise<AttendanceOverviewStats> {
|
||||
// 简化实现:基于已有查询统计
|
||||
const records = await getAttendanceRecords(params)
|
||||
const items = records.items
|
||||
const total = items.length
|
||||
const present = items.filter((r) => r.status === "present").length
|
||||
const absent = items.filter((r) => r.status === "absent").length
|
||||
const late = items.filter((r) => r.status === "late").length
|
||||
const earlyLeave = items.filter((r) => r.status === "early_leave").length
|
||||
const excused = items.filter((r) => r.status === "excused").length
|
||||
// 直接使用 SQL 聚合查询,避免分页截断导致统计失真(P0 修复)
|
||||
const conditions: SQL[] = []
|
||||
|
||||
const scopeFilter = buildScopeFilter(params.scope)
|
||||
if (scopeFilter) conditions.push(scopeFilter)
|
||||
if (params.scope.type === "class_members" && params.currentUserId) {
|
||||
conditions.push(eq(attendanceRecords.studentId, params.currentUserId))
|
||||
}
|
||||
if (params.classId) conditions.push(eq(attendanceRecords.classId, params.classId))
|
||||
if (params.date) conditions.push(eq(attendanceRecords.date, new Date(params.date)))
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
totalRecords: count(),
|
||||
presentCount: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'present' THEN 1 ELSE 0 END), 0)`,
|
||||
absentCount: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'absent' THEN 1 ELSE 0 END), 0)`,
|
||||
lateCount: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'late' THEN 1 ELSE 0 END), 0)`,
|
||||
earlyLeaveCount: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'early_leave' THEN 1 ELSE 0 END), 0)`,
|
||||
excusedCount: sql<number>`COALESCE(SUM(CASE WHEN ${attendanceRecords.status} = 'excused' THEN 1 ELSE 0 END), 0)`,
|
||||
})
|
||||
.from(attendanceRecords)
|
||||
.where(where)
|
||||
|
||||
const total = Number(row?.totalRecords ?? 0)
|
||||
const present = Number(row?.presentCount ?? 0)
|
||||
const absent = Number(row?.absentCount ?? 0)
|
||||
const late = Number(row?.lateCount ?? 0)
|
||||
const earlyLeave = Number(row?.earlyLeaveCount ?? 0)
|
||||
const excused = Number(row?.excusedCount ?? 0)
|
||||
|
||||
return {
|
||||
totalRecords: total,
|
||||
presentCount: present,
|
||||
|
||||
@@ -221,6 +221,22 @@ export const getActiveStudentIdsByClassId = async (classId: string): Promise<str
|
||||
return rows.map((r) => r.studentId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取班级所有活跃学生基本信息(id/name/email),按姓名升序。
|
||||
* 供跨模块调用使用(如考勤点名),避免直接查询 classEnrollments 表。
|
||||
*/
|
||||
export const getClassActiveStudentsWithInfo = async (
|
||||
classId: string
|
||||
): Promise<Array<{ id: string; name: string; email: string }>> => {
|
||||
const rows = await db
|
||||
.select({ id: users.id, name: users.name, email: users.email })
|
||||
.from(classEnrollments)
|
||||
.innerJoin(users, eq(users.id, classEnrollments.studentId))
|
||||
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
|
||||
.orderBy(asc(users.name))
|
||||
return rows.map((r) => ({ id: r.id, name: r.name ?? "Unknown", email: r.email }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取教师在一个班级所教的科目 ID 列表。
|
||||
* 参数顺序为 (classId, teacherId),供跨模块调用使用。
|
||||
|
||||
@@ -54,6 +54,24 @@ const requireCourseId = (formData: FormData): string => {
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验当前用户对课程的管理权限(资源归属校验)。
|
||||
* - admin(scope=all):直接放行
|
||||
* - teacher(其他 scope):必须为课程的授课教师
|
||||
*/
|
||||
async function assertCourseOwnership(
|
||||
courseId: string,
|
||||
ctx: Awaited<ReturnType<typeof requirePermission>>
|
||||
): Promise<{ ok: boolean; message?: string }> {
|
||||
if (ctx.dataScope.type === "all") return { ok: true }
|
||||
const course = await getElectiveCourseById(courseId)
|
||||
if (!course) return { ok: false, message: "Course not found" }
|
||||
if (course.teacherId !== ctx.userId) {
|
||||
return { ok: false, message: "You do not own this course" }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
export async function createElectiveCourseAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
@@ -97,9 +115,12 @@ export async function updateElectiveCourseAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
const existing = await getElectiveCourseById(id)
|
||||
if (!existing) return { success: false, message: "Course not found" }
|
||||
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
|
||||
const ownership = await assertCourseOwnership(id, ctx)
|
||||
if (!ownership.ok) {
|
||||
return { success: false, message: ownership.message ?? "Ownership check failed" }
|
||||
}
|
||||
|
||||
const parsed = UpdateElectiveCourseSchema.safeParse({
|
||||
name: formData.get("name") || undefined,
|
||||
@@ -138,11 +159,13 @@ export async function deleteElectiveCourseAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
const id = requireCourseId(formData)
|
||||
|
||||
const existing = await getElectiveCourseById(id)
|
||||
if (!existing) return { success: false, message: "Course not found" }
|
||||
const ownership = await assertCourseOwnership(id, ctx)
|
||||
if (!ownership.ok) {
|
||||
return { success: false, message: ownership.message ?? "Ownership check failed" }
|
||||
}
|
||||
|
||||
await deleteElectiveCourse(id)
|
||||
revalidateElectivePaths()
|
||||
@@ -157,8 +180,14 @@ export async function openSelectionAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
const courseId = requireCourseId(formData)
|
||||
|
||||
const ownership = await assertCourseOwnership(courseId, ctx)
|
||||
if (!ownership.ok) {
|
||||
return { success: false, message: ownership.message ?? "Ownership check failed" }
|
||||
}
|
||||
|
||||
await openSelection(courseId)
|
||||
revalidateElectivePaths(courseId)
|
||||
return { success: true, message: "Selection opened" }
|
||||
@@ -172,8 +201,14 @@ export async function closeSelectionAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
const courseId = requireCourseId(formData)
|
||||
|
||||
const ownership = await assertCourseOwnership(courseId, ctx)
|
||||
if (!ownership.ok) {
|
||||
return { success: false, message: ownership.message ?? "Ownership check failed" }
|
||||
}
|
||||
|
||||
await closeSelection(courseId)
|
||||
revalidateElectivePaths(courseId)
|
||||
return { success: true, message: "Selection closed" }
|
||||
@@ -187,7 +222,7 @@ export async function runLotteryAction(
|
||||
formData: FormData
|
||||
): Promise<ActionState<{ enrolled: number; waitlist: number }>> {
|
||||
try {
|
||||
await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
const ctx = await requirePermission(Permissions.ELECTIVE_MANAGE)
|
||||
const parsed = RunLotterySchema.safeParse({
|
||||
courseId: formData.get("courseId"),
|
||||
})
|
||||
@@ -198,6 +233,12 @@ export async function runLotteryAction(
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const ownership = await assertCourseOwnership(parsed.data.courseId, ctx)
|
||||
if (!ownership.ok) {
|
||||
return { success: false, message: ownership.message ?? "Ownership check failed" }
|
||||
}
|
||||
|
||||
const result = await runLottery(parsed.data.courseId)
|
||||
revalidateElectivePaths(parsed.data.courseId)
|
||||
return {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
} from "@/shared/components/ui/select"
|
||||
|
||||
import { createElectiveCourseAction, updateElectiveCourseAction } from "../actions"
|
||||
import type { ElectiveCourseWithDetails } from "../types"
|
||||
import type { ElectiveCourseWithDetails, ElectiveSelectionMode } from "../types"
|
||||
|
||||
type Mode = "create" | "edit"
|
||||
|
||||
@@ -27,6 +28,9 @@ interface Option {
|
||||
name: string
|
||||
}
|
||||
|
||||
const isSelectionMode = (v: string): v is ElectiveSelectionMode =>
|
||||
v === "fcfs" || v === "lottery"
|
||||
|
||||
export function ElectiveCourseForm({
|
||||
mode,
|
||||
course,
|
||||
@@ -43,12 +47,13 @@ export function ElectiveCourseForm({
|
||||
backHref?: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("elective")
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
|
||||
const [subjectId, setSubjectId] = useState(course?.subjectId ?? "")
|
||||
const [gradeId, setGradeId] = useState(course?.gradeId ?? "")
|
||||
const [teacherId, setTeacherId] = useState(course?.teacherId ?? "")
|
||||
const [selectionMode, setSelectionMode] = useState(course?.selectionMode ?? "fcfs")
|
||||
const [selectionMode, setSelectionMode] = useState<ElectiveSelectionMode>(course?.selectionMode ?? "fcfs")
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
setIsWorking(true)
|
||||
@@ -200,14 +205,19 @@ export function ElectiveCourseForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Selection Mode</Label>
|
||||
<Select value={selectionMode} onValueChange={(v) => setSelectionMode(v as "fcfs" | "lottery")}>
|
||||
<Label>{t("fields.selectionMode")}</Label>
|
||||
<Select
|
||||
value={selectionMode}
|
||||
onValueChange={(v) => {
|
||||
if (isSelectionMode(v)) setSelectionMode(v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fcfs">First Come First Served</SelectItem>
|
||||
<SelectItem value="lottery">Lottery</SelectItem>
|
||||
<SelectItem value="fcfs">{t("selectionMode.fcfs")}</SelectItem>
|
||||
<SelectItem value="lottery">{t("selectionMode.lottery")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="selectionMode" value={selectionMode} />
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useTransition } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { Plus, Pencil, Lock, Unlock, Shuffle, Trash2 } from "lucide-react"
|
||||
|
||||
@@ -10,6 +11,7 @@ import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
import {
|
||||
@@ -37,13 +39,14 @@ export function ElectiveCourseList({
|
||||
canManage?: boolean
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("elective")
|
||||
const { hasPermission } = usePermission()
|
||||
const manageResolved = canManage ?? hasPermission(Permissions.ELECTIVE_MANAGE)
|
||||
const [pendingId, setPendingId] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const runAction = async (
|
||||
action: (prevState: never, formData: FormData) => Promise<{ success: boolean; message?: string }>,
|
||||
const runAction = async <T,>(
|
||||
action: (prevState: ActionState<T> | null, formData: FormData) => Promise<ActionState<T>>,
|
||||
courseId: string,
|
||||
successMsg: string
|
||||
) => {
|
||||
@@ -51,7 +54,7 @@ export function ElectiveCourseList({
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.set("courseId", courseId)
|
||||
const res = await action(null as never, formData)
|
||||
const res = await action(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(res.message ?? successMsg)
|
||||
router.refresh()
|
||||
@@ -82,13 +85,13 @@ export function ElectiveCourseList({
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{courses.length} course{courses.length === 1 ? "" : "s"}
|
||||
{courses.length} {t("title.adminList")}
|
||||
</p>
|
||||
{manageResolved && createHref ? (
|
||||
<Button asChild>
|
||||
<a href={createHref}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Course
|
||||
{t("actions.create")}
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -96,8 +99,8 @@ export function ElectiveCourseList({
|
||||
|
||||
{courses.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No elective courses"
|
||||
description="There are no elective courses available."
|
||||
title={t("list.empty")}
|
||||
description={t("list.emptyDescription")}
|
||||
icon={Plus}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
|
||||
95
src/shared/i18n/messages/en/attendance.json
Normal file
95
src/shared/i18n/messages/en/attendance.json
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"title": {
|
||||
"adminOverview": "Attendance Overview",
|
||||
"teacherRecords": "Attendance Records",
|
||||
"teacherStats": "Attendance Statistics",
|
||||
"sheet": "Record Attendance",
|
||||
"student": "My Attendance",
|
||||
"parent": "Children Attendance",
|
||||
"rules": "Attendance Rules"
|
||||
},
|
||||
"description": {
|
||||
"adminOverview": "View attendance records for all classes school-wide.",
|
||||
"teacherRecords": "Manage student attendance records.",
|
||||
"teacherStats": "View class attendance statistics and analysis.",
|
||||
"student": "View your attendance summary and records.",
|
||||
"parent": "View your children's attendance summary and warnings."
|
||||
},
|
||||
"status": {
|
||||
"present": "Present",
|
||||
"absent": "Absent",
|
||||
"late": "Late",
|
||||
"early_leave": "Early Leave",
|
||||
"excused": "Excused"
|
||||
},
|
||||
"stats": {
|
||||
"totalRecords": "Total Records",
|
||||
"present": "Present",
|
||||
"absent": "Absent",
|
||||
"late": "Late",
|
||||
"earlyLeave": "Early Leave",
|
||||
"excused": "Excused",
|
||||
"attendanceRate": "Attendance Rate",
|
||||
"lateRate": "Late Rate",
|
||||
"recentRecords": "Recent Records"
|
||||
},
|
||||
"filters": {
|
||||
"class": "Class",
|
||||
"status": "Status",
|
||||
"date": "Date",
|
||||
"allClasses": "All Classes",
|
||||
"allStatuses": "All Statuses"
|
||||
},
|
||||
"actions": {
|
||||
"record": "Record Attendance",
|
||||
"stats": "Statistics",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"markAllPresent": "Mark All Present"
|
||||
},
|
||||
"list": {
|
||||
"empty": "No attendance records",
|
||||
"emptyDescription": "No attendance records have been generated yet.",
|
||||
"emptyTeacherDescription": "Start recording attendance for your class.",
|
||||
"columns": {
|
||||
"student": "Student",
|
||||
"class": "Class",
|
||||
"date": "Date",
|
||||
"status": "Status",
|
||||
"remark": "Remark",
|
||||
"recorder": "Recorder",
|
||||
"createdAt": "Created At"
|
||||
}
|
||||
},
|
||||
"sheet": {
|
||||
"selectClass": "Select Class",
|
||||
"selectDate": "Select Date",
|
||||
"noStudents": "No students in this class",
|
||||
"confirmDelete": "Are you sure you want to delete this attendance record?",
|
||||
"saved": "Attendance saved",
|
||||
"updated": "Attendance updated",
|
||||
"deleted": "Attendance record deleted"
|
||||
},
|
||||
"rules": {
|
||||
"lateThreshold": "Late Threshold (minutes)",
|
||||
"earlyLeaveThreshold": "Early Leave Threshold (minutes)",
|
||||
"enableAutoMark": "Enable Auto Mark",
|
||||
"saved": "Attendance rules saved"
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Attendance record not found",
|
||||
"noOwnership": "You do not own this attendance record",
|
||||
"invalidForm": "Invalid form data",
|
||||
"unexpected": "Unexpected error"
|
||||
},
|
||||
"parent": {
|
||||
"warningTitle": "Attendance Warnings",
|
||||
"rateCardTitle": "Attendance Rate Summary",
|
||||
"calendarTitle": "Attendance Calendar",
|
||||
"noWarnings": "No attendance warnings",
|
||||
"absentWarning": "{count} absence(s)",
|
||||
"lateWarning": "{count} late arrival(s)",
|
||||
"lowRateWarning": "Attendance rate {rate}% below threshold"
|
||||
}
|
||||
}
|
||||
96
src/shared/i18n/messages/en/elective.json
Normal file
96
src/shared/i18n/messages/en/elective.json
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"title": {
|
||||
"adminList": "Elective Courses",
|
||||
"create": "Create Course",
|
||||
"edit": "Edit Course",
|
||||
"teacher": "My Elective Courses",
|
||||
"student": "Course Selection"
|
||||
},
|
||||
"description": {
|
||||
"adminList": "Manage elective courses, open/close selection and lottery.",
|
||||
"teacher": "View and manage the elective courses you teach.",
|
||||
"student": "Browse available courses and make selections."
|
||||
},
|
||||
"status": {
|
||||
"draft": "Draft",
|
||||
"open": "Open",
|
||||
"closed": "Closed",
|
||||
"cancelled": "Cancelled"
|
||||
},
|
||||
"selectionMode": {
|
||||
"fcfs": "First Come First Served",
|
||||
"lottery": "Lottery"
|
||||
},
|
||||
"selectionStatus": {
|
||||
"selected": "Selected",
|
||||
"enrolled": "Enrolled",
|
||||
"waitlist": "Waitlist",
|
||||
"dropped": "Dropped",
|
||||
"rejected": "Rejected"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Course Name",
|
||||
"subject": "Subject",
|
||||
"teacher": "Teacher",
|
||||
"grade": "Grade",
|
||||
"description": "Description",
|
||||
"capacity": "Capacity",
|
||||
"enrolled": "Enrolled",
|
||||
"classroom": "Classroom",
|
||||
"schedule": "Schedule",
|
||||
"startDate": "Start Date",
|
||||
"endDate": "End Date",
|
||||
"selectionStart": "Selection Start",
|
||||
"selectionEnd": "Selection End",
|
||||
"selectionMode": "Selection Mode",
|
||||
"credit": "Credit"
|
||||
},
|
||||
"actions": {
|
||||
"create": "Create Course",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"openSelection": "Open Selection",
|
||||
"closeSelection": "Close Selection",
|
||||
"runLottery": "Run Lottery",
|
||||
"select": "Select",
|
||||
"drop": "Drop",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save"
|
||||
},
|
||||
"list": {
|
||||
"empty": "No elective courses",
|
||||
"emptyStudent": "No available courses",
|
||||
"emptyDescription": "No elective courses have been created yet."
|
||||
},
|
||||
"form": {
|
||||
"createTitle": "Create Elective Course",
|
||||
"editTitle": "Edit Elective Course",
|
||||
"namePlaceholder": "Enter course name",
|
||||
"descriptionPlaceholder": "Enter course description"
|
||||
},
|
||||
"student": {
|
||||
"mySelections": "My Selections",
|
||||
"availableCourses": "Available Courses",
|
||||
"selected": "Selected",
|
||||
"enrolled": "Enrolled",
|
||||
"waitlist": "Waitlist",
|
||||
"capacityFull": "Capacity full",
|
||||
"selectSuccess": "Course selected successfully",
|
||||
"dropSuccess": "Course dropped successfully",
|
||||
"confirmDrop": "Are you sure you want to drop this course?"
|
||||
},
|
||||
"lottery": {
|
||||
"result": "Lottery result: {enrolled} enrolled, {waitlist} waitlisted",
|
||||
"running": "Running lottery..."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Course not found",
|
||||
"noOwnership": "You do not own this course",
|
||||
"capacityFull": "Course capacity is full",
|
||||
"alreadySelected": "You have already selected this course",
|
||||
"selectionClosed": "Selection is closed",
|
||||
"gradeMismatch": "Your grade does not match the course requirement",
|
||||
"invalidForm": "Invalid form data",
|
||||
"unexpected": "Unexpected error"
|
||||
}
|
||||
}
|
||||
95
src/shared/i18n/messages/zh-CN/attendance.json
Normal file
95
src/shared/i18n/messages/zh-CN/attendance.json
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"title": {
|
||||
"adminOverview": "考勤总览",
|
||||
"teacherRecords": "考勤记录",
|
||||
"teacherStats": "考勤统计",
|
||||
"sheet": "录入考勤",
|
||||
"student": "我的考勤",
|
||||
"parent": "子女考勤",
|
||||
"rules": "考勤规则"
|
||||
},
|
||||
"description": {
|
||||
"adminOverview": "查看全校所有班级的考勤记录。",
|
||||
"teacherRecords": "管理学生考勤记录。",
|
||||
"teacherStats": "查看班级考勤统计分析。",
|
||||
"student": "查看个人考勤汇总与记录。",
|
||||
"parent": "查看子女考勤汇总与异常预警。"
|
||||
},
|
||||
"status": {
|
||||
"present": "出勤",
|
||||
"absent": "缺勤",
|
||||
"late": "迟到",
|
||||
"early_leave": "早退",
|
||||
"excused": "请假"
|
||||
},
|
||||
"stats": {
|
||||
"totalRecords": "总记录数",
|
||||
"present": "出勤",
|
||||
"absent": "缺勤",
|
||||
"late": "迟到",
|
||||
"earlyLeave": "早退",
|
||||
"excused": "请假",
|
||||
"attendanceRate": "出勤率",
|
||||
"lateRate": "迟到率",
|
||||
"recentRecords": "最近记录"
|
||||
},
|
||||
"filters": {
|
||||
"class": "班级",
|
||||
"status": "状态",
|
||||
"date": "日期",
|
||||
"allClasses": "全部班级",
|
||||
"allStatuses": "全部状态"
|
||||
},
|
||||
"actions": {
|
||||
"record": "录入考勤",
|
||||
"stats": "统计分析",
|
||||
"save": "保存",
|
||||
"delete": "删除",
|
||||
"cancel": "取消",
|
||||
"markAllPresent": "全部标记到场"
|
||||
},
|
||||
"list": {
|
||||
"empty": "暂无考勤记录",
|
||||
"emptyDescription": "系统中尚未产生任何考勤记录。",
|
||||
"emptyTeacherDescription": "开始为您的班级录入考勤。",
|
||||
"columns": {
|
||||
"student": "学生",
|
||||
"class": "班级",
|
||||
"date": "日期",
|
||||
"status": "状态",
|
||||
"remark": "备注",
|
||||
"recorder": "记录人",
|
||||
"createdAt": "创建时间"
|
||||
}
|
||||
},
|
||||
"sheet": {
|
||||
"selectClass": "选择班级",
|
||||
"selectDate": "选择日期",
|
||||
"noStudents": "该班级暂无学生",
|
||||
"confirmDelete": "确定删除此条考勤记录吗?",
|
||||
"saved": "考勤已保存",
|
||||
"updated": "考勤已更新",
|
||||
"deleted": "考勤记录已删除"
|
||||
},
|
||||
"rules": {
|
||||
"lateThreshold": "迟到阈值(分钟)",
|
||||
"earlyLeaveThreshold": "早退阈值(分钟)",
|
||||
"enableAutoMark": "启用自动标记",
|
||||
"saved": "考勤规则已保存"
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "考勤记录不存在",
|
||||
"noOwnership": "您无权操作此考勤记录",
|
||||
"invalidForm": "表单数据无效",
|
||||
"unexpected": "发生未知错误"
|
||||
},
|
||||
"parent": {
|
||||
"warningTitle": "考勤异常预警",
|
||||
"rateCardTitle": "出勤率汇总",
|
||||
"calendarTitle": "考勤月历",
|
||||
"noWarnings": "暂无考勤异常",
|
||||
"absentWarning": "{count} 次缺勤",
|
||||
"lateWarning": "{count} 次迟到",
|
||||
"lowRateWarning": "出勤率 {rate}% 低于阈值"
|
||||
}
|
||||
}
|
||||
96
src/shared/i18n/messages/zh-CN/elective.json
Normal file
96
src/shared/i18n/messages/zh-CN/elective.json
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"title": {
|
||||
"adminList": "选修课程",
|
||||
"create": "创建课程",
|
||||
"edit": "编辑课程",
|
||||
"teacher": "我的选修课",
|
||||
"student": "选课中心"
|
||||
},
|
||||
"description": {
|
||||
"adminList": "管理选修课程、开放/关闭选课与抽签。",
|
||||
"teacher": "查看和管理您教授的选修课程。",
|
||||
"student": "浏览可选课程并进行选课。"
|
||||
},
|
||||
"status": {
|
||||
"draft": "草稿",
|
||||
"open": "开放选课",
|
||||
"closed": "已关闭",
|
||||
"cancelled": "已取消"
|
||||
},
|
||||
"selectionMode": {
|
||||
"fcfs": "先到先得",
|
||||
"lottery": "抽签"
|
||||
},
|
||||
"selectionStatus": {
|
||||
"selected": "已选",
|
||||
"enrolled": "已录取",
|
||||
"waitlist": "候补",
|
||||
"dropped": "已退选",
|
||||
"rejected": "已拒绝"
|
||||
},
|
||||
"fields": {
|
||||
"name": "课程名称",
|
||||
"subject": "科目",
|
||||
"teacher": "授课教师",
|
||||
"grade": "年级",
|
||||
"description": "课程描述",
|
||||
"capacity": "容量",
|
||||
"enrolled": "已录取",
|
||||
"classroom": "教室",
|
||||
"schedule": "上课时间",
|
||||
"startDate": "开始日期",
|
||||
"endDate": "结束日期",
|
||||
"selectionStart": "选课开始",
|
||||
"selectionEnd": "选课结束",
|
||||
"selectionMode": "选课模式",
|
||||
"credit": "学分"
|
||||
},
|
||||
"actions": {
|
||||
"create": "创建课程",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"openSelection": "开放选课",
|
||||
"closeSelection": "关闭选课",
|
||||
"runLottery": "执行抽签",
|
||||
"select": "选课",
|
||||
"drop": "退课",
|
||||
"cancel": "取消",
|
||||
"save": "保存"
|
||||
},
|
||||
"list": {
|
||||
"empty": "暂无选修课程",
|
||||
"emptyStudent": "暂无可选课程",
|
||||
"emptyDescription": "系统中尚未创建任何选修课程。"
|
||||
},
|
||||
"form": {
|
||||
"createTitle": "创建选修课程",
|
||||
"editTitle": "编辑选修课程",
|
||||
"namePlaceholder": "请输入课程名称",
|
||||
"descriptionPlaceholder": "请输入课程描述"
|
||||
},
|
||||
"student": {
|
||||
"mySelections": "我的选课",
|
||||
"availableCourses": "可选课程",
|
||||
"selected": "已选",
|
||||
"enrolled": "已录取",
|
||||
"waitlist": "候补",
|
||||
"capacityFull": "名额已满",
|
||||
"selectSuccess": "选课成功",
|
||||
"dropSuccess": "退课成功",
|
||||
"confirmDrop": "确定退选此课程吗?"
|
||||
},
|
||||
"lottery": {
|
||||
"result": "抽签结果:录取 {enrolled} 人,候补 {waitlist} 人",
|
||||
"running": "抽签进行中..."
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "课程不存在",
|
||||
"noOwnership": "您无权操作此课程",
|
||||
"capacityFull": "课程名额已满",
|
||||
"alreadySelected": "您已选过此课程",
|
||||
"selectionClosed": "选课已关闭",
|
||||
"gradeMismatch": "您的年级不符合课程要求",
|
||||
"invalidForm": "表单数据无效",
|
||||
"unexpected": "发生未知错误"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user