diff --git a/docs/architecture/audit/exam-homework-audit-report.md b/docs/architecture/audit/exam-homework-audit-report.md new file mode 100644 index 0000000..472a9fc --- /dev/null +++ b/docs/architecture/audit/exam-homework-audit-report.md @@ -0,0 +1,396 @@ +# 考试和作业模块审计报告 + +> 审计范围:`exams`(考试/试卷/AI 出题)、`homework`(作业/指派/作答/批改)、`proctoring`(监考/防作弊)三个相互耦合的模块,以及它们在 `app/(dashboard)` 下的对应路由页面。 + +--- + +## 一、现有实现概要 + +### 1.1 文件分布 + +| 层 | 模块 | 关键文件 | 行数 | +|----|------|----------|------| +| app 路由 | teacher/exams | `page.tsx` / `all/page.tsx` / `create/page.tsx` / `[id]/build/page.tsx` / `[id]/proctoring/page.tsx` / `grading/page.tsx`(重定向) / `grading/[submissionId]/page.tsx`(重定向) | - | +| app 路由 | teacher/homework | `assignments/page.tsx` / `assignments/create/page.tsx` / `assignments/[id]/page.tsx` / `assignments/[id]/submissions/page.tsx` | - | +| app 路由 | student/learning/assignments | `page.tsx` / `[assignmentId]/page.tsx` + `loading.tsx` | - | +| modules | exams | `actions.ts`(691) / `ai-pipeline.ts`(857) / `data-access.ts`(473) / `types.ts`(31) / `hooks/use-exam-preview.ts`(295) / `utils/normalize-structure.ts`(57) / `components/*`(18 文件) | - | +| modules | homework | `actions.ts`(239) / `data-access.ts`(598) / `data-access-write.ts`(285) / `data-access-classes.ts`(232) / `stats-service.ts`(425) / `schema.ts`(29) / `types.ts`(186) / `components/*`(11 文件) | - | +| modules | proctoring | `actions.ts`(139) / `data-access.ts`(409) / `types.ts`(136) / `components/*`(3 文件) | - | + +### 1.2 主要数据流 + +1. **考试创建**:`teacher/exams/create` → `createExamAction` / `createAiExamAction` → `persistExamDraft` / `persistAiGeneratedExamDraft` → `db.insert(exams)`。 +2. **组卷**:`teacher/exams/[id]/build` → `getExamById` + `getQuestions` → `ExamAssembly` → `updateExamAction`。 +3. **作业下发**:`teacher/homework/assignments/create` → `createHomeworkAssignmentAction` → `getExamWithQuestionsForHomework`(跨模块调用 exams data-access)→ `createHomeworkAssignment`(事务写入 assignments + questions + targets)。 +4. **学生作答**:`student/learning/assignments/[assignmentId]` → `getStudentHomeworkTakeData` → `HomeworkTakeView` → `startHomeworkSubmissionAction` / `saveHomeworkAnswerAction` / `submitHomeworkAction`。 +5. **教师批改**:`teacher/homework/assignments/[id]/submissions` → `getHomeworkSubmissions` → 跳转 `[submissionId]` → `getHomeworkSubmissionDetails` → `HomeworkGradingView` → `gradeHomeworkSubmissionAction`。 +6. **监考**:`teacher/exams/[id]/proctoring` → `getProctoringDashboardAction` → `getExamForProctoring` + `getExamProctoringSummary` + `getStudentProctoringStatuses` + `getRecentProctoringEvents`。 + +### 1.3 架构图覆盖情况 + +`docs/architecture/004_architecture_impact_map.md` 已记录 exams(§2.2)、homework(§2.3)、proctoring(§2.21)三个模块的导出函数、依赖关系、已知问题和文件清单。架构图信息基本完整,但以下细节未记录: + +- `homework/components/homework-assignment-exam-error-explorer.tsx` 等错误分析组件未在文件清单中列出。 +- `exams/components/assembly/*` 子目录的 4 个组件未单独记录行数。 +- proctoring 的 `exam-mode-config.tsx` 死代码状态已在已知问题中标注,但未记录其与 `ExamForm` 的集成缺失原因。 + +--- + +## 二、现存问题与原因分析 + +### 2.1 国际化缺失(严重) + +**问题**:该模块几乎所有用户可见文本均为硬编码,且中英文混杂。 + +**出现位置**: +- `src/modules/exams/components/exam-form.tsx`:硬编码英文 `"Exam draft created"`、`"Redirecting to exam builder..."`、`"Missing subject or grade configuration"`。 +- `src/modules/exams/components/exam-columns.tsx`:硬编码 `"Exam Info"`、`"Status"`、`"Stats"`、`"Difficulty"`、`"Easy"`、`"Medium"`、`"Hard"`。 +- `src/modules/exams/components/exam-actions.tsx`:硬编码 `"Preview Exam"`、`"Copy ID"`、`"Edit"`、`"Build"`、`"Publish"`、`"Archive"`、`"Delete"`、`"Are you absolutely sure?"`。 +- `src/modules/homework/components/homework-take-view.tsx`:硬编码 `"Questions"`、`"Start Assignment"`、`"Submit Assignment"`、`"Save Answer"`、`"Due Date"`、`"Attempts"`、`"Description"`、`"Progress"`、`"Confirm Submission"`。 +- `src/modules/homework/components/homework-grading-view.tsx`:硬编码 `"Grading Summary"`、`"Total Score"`、`"Correct"`、`"Incorrect"`、`"Partial"`、`"Submit Grades"`、`"Previous Student"`、`"Next Student"`。 +- `src/modules/homework/components/homework-assignment-form.tsx`:硬编码中文 `"快速作业"`、`"考试派生作业"`、`"直接输入标题和描述,无需建题"`、`"从已有考试派生作业"`。 +- `src/app/(dashboard)/teacher/homework/assignments/page.tsx`:硬编码中文 `"作业列表"`、`"管理作业,查看提交率与批改进度。"`、`"创建作业"`、`"暂无作业"`、`"按班级筛选:"`、`"清除筛选"`、`"标题"`、`"状态"`、`"截止时间"`、`"提交率"`、`"平均分"`、`"逾期"`、`"来源考试"`、`"创建时间"`。 +- `src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx`:硬编码英文 `"Submissions"`、`"Student"`、`"Status"`、`"Submitted"`、`"Score"`、`"Action"`、`"Grade"`、`"Back"`、`"Open Assignment"`。 +- `src/app/(dashboard)/student/learning/assignments/page.tsx`:硬编码英文 `"Assignments"`、`"Your homework and practice assignments."`、`"No assignments"`、`"Pending"`、`"Completed"`、`"Overdue"`、`"Due"`、`"Attempts"`、`"Score"`、`"Start"`、`"Continue"`、`"View"`、`"Review"`。 +- `src/modules/proctoring/components/exam-mode-config.tsx`:硬编码中文 `"考试模式"`、`"模式"`、`"考试时长(分钟)"`、`"题目乱序"`、`"启用防作弊监控"`、`"允许迟开始"`、`"迟到宽限时间(分钟)"`。 + +**问题原因**:模块在 v3 i18n 体系建立前已实现,后续未回填翻译键。 + +**违反规则**:项目规则"所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键"。 + +**直接后果**: +- 切换到英文 locale 后,作业列表页仍显示中文;考试列表页仍显示英文。多角色(admin/teacher/parent/student)无法获得一致的语言体验。 +- 国际化交付阻塞,无法满足 K12 学校多语言场景。 + +### 2.2 类型安全问题 + +**问题**:多处使用 `as any` / `as unknown` 断言,违反 TypeScript 严格规范。 + +**出现位置**: +- `src/modules/exams/components/exam-form.tsx:38`:`resolver: zodResolver(formSchema) as any`(注释 `eslint-disable`)。 +- `src/modules/exams/components/exam-form.tsx:163,168`:`form.handleSubmit(onSubmit as any)`(两处 `eslint-disable`)。 +- `src/modules/exams/components/exam-actions.tsx:60`:`questionById.set(q.id, q as unknown as Question)`。 +- `src/modules/exams/components/exam-actions.tsx:63`:`const hydrate = (nodes: any[]): ExamNode[]`(`eslint-disable`)。 +- `src/modules/homework/components/homework-take-view.tsx:346-347`:`(prev[q.questionId]?.answer as string[])`。 +- `src/modules/homework/components/homework-take-view.tsx:468`:`(answersByQuestionId[q.questionId]?.answer as unknown[])`。 +- `src/modules/homework/components/homework-grading-view.tsx:199`:`(ans.questionContent.options as ChoiceOption[])`。 +- `src/modules/homework/data-access.ts:484`:`structure: assignment.structure as unknown`。 + +**问题原因**:zodResolver 与 react-hook-form 类型不兼容时偷懒用 `as any`;题目内容为 `unknown` 时未做类型守卫直接断言。 + +**违反规则**:项目规则"禁止 `any`"、"禁止 `as` 断言(除非从 `unknown` 转换或测试中,需注释原因)"。 + +**直接后果**:类型系统形同虚设,运行时错误无法在编译期捕获;重构时易引入隐性 bug。 + +### 2.3 权限校验不完整 + +**问题**:`gradeHomeworkSubmissionAction` 未校验教师对该提交记录的访问权限。 + +**出现位置**:`src/modules/homework/actions.ts:249-292`。 + +**问题原因**:`gradeHomeworkSubmissionAction` 仅调用 `requirePermission(Permissions.HOMEWORK_GRADE)`,未校验当前教师是否为该作业的创建者、或该学生所在班级的任课教师。任意拥有 `HOMEWORK_GRADE` 权限的教师均可批改任意学生的任意作业。 + +**违反规则**:项目规则"所有敏感数据查询必须在 data-access 层结合当前用户权限过滤,Server Action 二次校验"。 + +**直接后果**:横向越权风险——教师 A 可批改教师 B 的学生作业,篡改成绩。 + +### 2.4 错误边界与加载状态缺失 + +**问题**:考试和作业模块的页面缺少 React Error Boundary 和 Suspense 骨架屏。 + +**出现位置**: +- `src/app/(dashboard)/teacher/exams/[id]/build/page.tsx`:无 `error.tsx`、无 `loading.tsx`,`getExamById` 失败时整页 500。 +- `src/app/(dashboard)/teacher/exams/[id]/proctoring/page.tsx`:无 `error.tsx`、无 `loading.tsx`。 +- `src/app/(dashboard)/teacher/homework/assignments/[id]/page.tsx`:无 `error.tsx`、无 `loading.tsx`。 +- `src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx`:无 `error.tsx`、无 `loading.tsx`。 +- `src/app/(dashboard)/teacher/homework/assignments/create/page.tsx`:无 `loading.tsx`。 +- `src/app/(dashboard)/student/learning/assignments/[assignmentId]/page.tsx`:有 `loading.tsx` 但无 `error.tsx`。 +- 仅 `exams/all` 和 `exams/create` 有 `loading.tsx`。 + +**问题原因**:页面开发时未配套错误边界;Suspense 仅在 `exams/all` 使用。 + +**违反规则**:项目规则"每个独立的数据区块必须用 React Error Boundary 包裹"、"异步数据使用 React Suspense + 骨架屏"、"明确处理空数据、无权限、网络异常等边界状态"。 + +**直接后果**:数据库连接抖动或单条记录缺失会导致整页崩溃,无法降级展示。 + +### 2.5 组件复用不足 + +**问题**:题目渲染逻辑在作答页、批改页、复习页三处重复实现。 + +**出现位置**: +- `src/modules/homework/components/homework-take-view.tsx:248-400`:渲染 `single_choice` / `multiple_choice` / `judgment` / `text` 四种题型。 +- `src/modules/homework/components/homework-grading-view.tsx:155-328`:再次渲染同样四种题型(带正确答案高亮)。 +- `src/modules/homework/components/student-homework-review-view.tsx`:第三次渲染同样四种题型(带批改反馈)。 +- 三处都重复实现 `getQuestionText` / `getOptions` / `isRecord` 等工具函数。 + +**问题原因**:未抽象 `QuestionRenderer` / `QuestionAnswerInput` / `QuestionResultDisplay` 等复用组件。 + +**违反规则**:项目规则"最大化复用:识别四个角色共用的 UI 块和业务逻辑块,抽象为泛型组件和 hooks"、"组合优先:所有 UI 通过组件组合实现灵活性"。 + +**直接后果**:题型扩展(如填空、排序、拖拽)需改三处;样式不一致风险高;单测难以覆盖。 + +### 2.6 监考模块死代码 + +**问题**:`ExamModeConfig` 组件已实现但未集成到考试创建/编辑表单。 + +**出现位置**:`src/modules/proctoring/components/exam-mode-config.tsx`(230 行)从未被 import。 + +**问题原因**:架构图 §2.21 已标注"❌ P0:`exam-mode-config.tsx` 未集成到考试表单(死代码,监考功能无法启用)",但至今未修复。 + +**违反规则**:项目规则"如果架构图未覆盖该模块的任何部分,必须优先补全架构图再继续"——此处架构图已记录但代码未修复。 + +**直接后果**:监考功能(防作弊、限时、全屏强制)完全不可用;`proctoring` 模块的 `recordProctoringEventAction` 无前端触发路径。 + +### 2.7 文件行数超限 + +**问题**:`ai-pipeline.ts` 857 行,超过 800 行建议值。 + +**出现位置**:`src/modules/exams/ai-pipeline.ts`。 + +**问题原因**:混合了 AI 请求构造、响应解析、Zod 校验、题目归一化、结构生成 5 类职责。 + +**违反规则**:项目规则"Server Actions / Data Access 模块:建议 ≤ 800 行"、"超过建议行数时应考虑拆分"。 + +**直接后果**:维护困难;AI 供应商切换需改动整个文件。 + +### 2.8 可访问性缺陷 + +**问题**:交互元素缺少 ARIA 属性,颜色作为唯一信息载体。 + +**出现位置**: +- `src/modules/homework/components/homework-grading-view.tsx:156-158`:用 `border-l-emerald-500` / `border-l-red-500` 表示对错,无文本替代。 +- `src/modules/exams/components/exam-columns.tsx:110-121`:难度仅用色块表示,`text-[10px]` 标签为英文缩写。 +- `src/modules/homework/components/homework-take-view.tsx:471-486`:题目导航按钮 `aria-label` 为英文 `Jump to question ${i+1}`,未 i18n。 +- 批改页 `Correct`/`Incorrect` 按钮仅靠颜色区分状态。 + +**违反规则**:项目规则"可访问性(a11y):语义化标签、ARIA 属性、键盘导航"。 + +**直接后果**:色盲教师无法区分对错;屏幕阅读器用户体验差。 + +### 2.9 性能问题 + +**问题**:`getHomeworkSubmissionDetails` 为获取前后导航 ID 拉取全部提交记录。 + +**出现位置**:`src/modules/homework/data-access.ts:540-548`。 + +```typescript +const allSubmissions = await db.query.homeworkSubmissions.findMany({ + where: eq(homeworkSubmissions.assignmentId, submission.assignmentId), + orderBy: [desc(homeworkSubmissions.updatedAt)], + columns: { id: true }, +}) +const currentIndex = allSubmissions.findIndex((s) => s.id === submissionId) +``` + +**问题原因**:未用 SQL 窗口函数或 `OFFSET`/`LIMIT` 获取相邻记录。 + +**违反规则**:项目规则"性能:优先使用 React Server Components 获取初始数据"——此处为 data-access 层低效查询。 + +**直接后果**:班级 50 人作业批改时,每次打开详情都拉取 50 条记录的 ID。 + +### 2.10 答案保存无防抖与离线支持 + +**问题**:学生作答时每题手动点击"Save Answer",无自动保存、无离线缓存。 + +**出现位置**:`src/modules/homework/components/homework-take-view.tsx:139-151`。 + +**问题原因**:未实现自动保存(防抖)和 `localStorage` 离线缓存。 + +**违反规则**:项目规则"明确处理网络异常等边界状态"。 + +**直接后果**:网络抖动时学生答案丢失;刷新页面(尽管有 `beforeunload` 警告)仍可能丢失未保存答案。 + +--- + +## 三、行业差距对比 + +### 3.1 与主流 K12 考试系统对比 + +| 功能 | 行业主流(如智学网、猿题库、Google Classroom) | 当前实现 | 差距影响 | +|------|------|------|------| +| 限时考试 | 支持设定考试时长,到时自动提交 | `ExamModeConfig` 已实现但未集成 | 教师无法组织课堂限时测验 | +| 题目乱序 | 每位学生题目顺序随机 | `ExamModeConfig` 已实现但未集成 | 防作弊能力缺失 | +| 监考模式 | 切屏检测、强制全屏、AI 行为分析 | `proctoring` 模块后端已实现,前端无入口 | 远程考试无法防作弊 | +| 自动批改 | 选择题/判断题提交后即时出分 | `homework-grading-view` 有 `applyAutoGrades` 但仅在打开批改页时计算,不回写 | 学生提交后看不到即时成绩 | +| 批量批改 | 列表页勾选多份提交批量打分 | 仅支持逐份批改 | 50 人班级批改效率低 | +| 评分量规(Rubric) | 文本题按维度打分 | 仅支持单分数 | 主观题批改粗放 | +| 考试分析 | 题目难度、区分度、知识点掌握度 | `homework/stats-service` 有作业分析,考试无分析 | 考试后无法复盘教学质量 | +| 学生答案草稿 | 自动保存 + 离线缓存 | 手动保存,无离线 | 弱网环境答案易丢 | +| 部分分自动判分 | 多选题漏选得部分分 | 全对才得分 | 评分不够精细 | +| 重考与补考 | 支持重考流程与成绩记录 | `maxAttempts` 已支持但无补考入口 | 补考场景需手动创建新作业 | + +### 3.2 多角色体验差距 + +| 角色 | 行业主流体验 | 当前实现 | 差距 | +|------|------|------|------| +| **教师** | 一站式工作台:创建→发布→监考→批改→分析 | 分散在 `/teacher/exams/*` 和 `/teacher/homework/*` 两个独立菜单 | 考试到作业的链路割裂 | +| **学生** | 统一"待办"入口:作业+考试+复习 | 仅 `/student/learning/assignments`,考试作答也走作业流程 | 考试与作业概念混淆 | +| **家长** | 查看孩子考试详情、错题本、趋势 | `parent` 模块仅有作业摘要,无考试详情 | 家长无法了解考试表现 | +| **管理员** | 全校考试统计、年级对比、教师工作量 | 无管理员视角的考试仪表盘 | 管理层无法宏观决策 | + +### 3.3 UI/UX 差距 + +- **空状态**:`exams/all` 有空状态,但 `homework/assignments/[id]/submissions` 无空状态(无提交时显示空表格)。 +- **加载骨架屏**:仅 `exams/all`、`exams/create`、`student/learning/assignments` 有;其余页面白屏加载。 +- **错误降级**:全模块无 `error.tsx`,任何数据加载失败均导致整页 500。 +- **移动端适配**:`homework-take-view` 和 `homework-grading-view` 使用 `lg:grid-cols-12`,移动端可正常显示但未优化触控体验(题目导航按钮过小)。 + +--- + +## 四、改进优先级建议 + +### P0(紧急,影响安全与核心功能) + +1. **补全 `gradeHomeworkSubmissionAction` 权限校验**:在 data-access 层新增 `getHomeworkSubmissionForGrading(submissionId, teacherId, dataScope)`,校验教师对该作业的访问权(创建者或班级任课教师)。Server Action 二次校验。 +2. **i18n 全量回填**:新建 `messages/zh-CN/exam-homework.json` 和 `messages/en/exam-homework.json`,提取该模块所有硬编码文本为翻译键;在 `i18n/request.ts` 注册新命名空间;组件改用 `useTranslations('examHomework')`。 +3. **集成 `ExamModeConfig` 到考试表单**:在 `exam-form.tsx` 中引入 `ExamModeConfig`,将 `examMode` / `durationMinutes` / `shuffleQuestions` / `antiCheatEnabled` 等字段纳入 `ExamFormValues`,持久化到 `exams` 表;`proctoring` 模块读取这些配置启用监考。 + +### P1(重要,影响可维护性与体验) + +4. **添加 Error Boundary 与 loading.tsx**:为 `exams/[id]/build`、`exams/[id]/proctoring`、`homework/assignments/[id]`、`homework/assignments/[id]/submissions`、`homework/assignments/create`、`student/learning/assignments/[assignmentId]` 配套 `error.tsx` + `loading.tsx`。 +5. **抽象题目渲染组件**:新建 `homework/components/question-renderer.tsx`,导出 `QuestionRenderer`(只读展示)、`QuestionAnswerInput`(作答交互)、`QuestionGradingPanel`(批改面板),三处页面改用组合模式复用。 +6. **清理类型断言**:`exam-form.tsx` 的 `as any` 改为正确泛型;`exam-actions.tsx` 的 `hydrate` 函数用类型守卫替代 `any[]`;`homework-take-view.tsx` / `homework-grading-view.tsx` 的 `as` 断言改为类型守卫。 +7. **拆分 `ai-pipeline.ts`**:按职责拆为 `ai-pipeline/request.ts`(请求构造)、`ai-pipeline/parse.ts`(响应解析+校验)、`ai-pipeline/structure.ts`(结构生成),原文件作为 re-export 入口。 +8. **优化 `getHomeworkSubmissionDetails` 相邻记录查询**:用 `LEAD`/`LAG` 窗口函数或两次 `LIMIT 1` 查询替代全量拉取。 + +### P2(增强,提升体验与可扩展性) + +9. **学生答案自动保存 + 离线缓存**:`homework-take-view` 增加 `useDebouncedAutoSave` hook,答案变更后 3 秒自动保存;同时写入 `localStorage`,断网时队列化重试。 +10. **考试分析仪表盘**:新增 `exams/components/exam-analytics-dashboard.tsx`,复用 `homework/stats-service` 模式,展示题目难度、区分度、知识点掌握度。 +11. **批量批改 UI**:`homework/assignments/[id]/submissions` 增加多选 + 批量打分(全对/全错/自定义分数)。 +12. **a11y 修复**:颜色指示器增加文本替代;题目导航按钮 `aria-label` i18n;批改页 `Correct`/`Incorrect` 按钮增加 `aria-pressed`。 +13. **配置驱动的角色渲染**:定义 `ExamHomeworkRoleConfig` 接口,各角色模块仅组合复用单元,新增角色只改配置。 + +--- + +## 五、架构图同步说明 + +本次审计发现架构图需补充以下信息: + +### 5.1 需补充的节点 + +1. **`004_architecture_impact_map.md` §2.2 exams 模块**: + - 文件清单补充 `components/assembly/exam-paper-preview.tsx`、`question-bank-list.tsx`、`selected-question-list.tsx`、`structure-editor.tsx` 四个组件的行数与职责。 + - 已知问题补充:`exam-mode-config.tsx` 未集成(与 proctoring 模块联动缺失)。 + +2. **`004_architecture_impact_map.md` §2.3 homework 模块**: + - 文件清单补充 `components/homework-assignment-exam-content-card.tsx`、`homework-assignment-exam-error-explorer.tsx`、`homework-assignment-exam-error-explorer-lazy.tsx`、`homework-assignment-exam-preview-pane.tsx`、`homework-assignment-question-error-detail-panel.tsx`、`homework-assignment-question-error-overview-card.tsx`、`student-homework-review-view.tsx` 七个组件的行数与职责。 + - 已知问题补充:`gradeHomeworkSubmissionAction` 权限校验不完整(P0 安全问题)。 + +3. **`004_architecture_impact_map.md` §2.21 proctoring 模块**: + - 已知问题补充:`ExamModeConfig` 未集成的根因是 `ExamFormValues` 未包含 `examMode` 字段,需扩展表单 schema。 + +4. **`005_architecture_data.json`**: + - `modules.exams.exports` 补充 `ExamModeConfig` 集成状态字段。 + - `modules.homework.knownIssues` 新增 `gradeHomeworkPermissionGap` 节点。 + - `dependencyMatrix` 补充 `proctoring → exams` 的 `examModeConfig` 依赖关系(当前仅记录 data-access 依赖,未记录 UI 集成依赖)。 + +### 5.2 无需修改的部分 + +- 三层架构依赖关系记录准确(`app → modules → shared`)。 +- 跨模块 data-access 调用关系记录完整(exams ↔ homework ↔ proctoring)。 +- 文件行数统计基本准确(`ai-pipeline.ts` 857 行已记录)。 + +--- + +## 六、重构方案设计(概要) + +### 6.1 完全解耦 + +定义 `ExamHomeworkServicePort` 接口,抽象数据依赖: + +```typescript +// modules/exam-homework/types/service-port.ts +export interface ExamHomeworkServicePort { + getExams(scope: DataScope): Promise + getExamById(id: string): Promise + getHomeworkAssignments(scope: DataScope): Promise + getStudentHomeworkTakeData(assignmentId: string, studentId: string): Promise + // ... 其余数据访问方法 +} + +export interface ExamHomeworkPermissionPort { + canGradeSubmission(teacherId: string, submissionId: string): Promise + canViewExam(userId: string, examId: string, scope: DataScope): Promise +} +``` + +通过 `ExamHomeworkServiceProvider`(React Context)注入实现,模块内部组件绝不 import 其他业务模块的 actions。 + +### 6.2 组合优先 + +抽象题目渲染组件: + +```typescript +// modules/exam-homework/components/question-renderer.tsx +export function QuestionRenderer({ + question, + mode, + children, +}: { + question: QuestionData + mode: 'take' | 'grade' | 'review' + children?: React.ReactNode +}) { ... } + +export function QuestionAnswerInput({ question, value, onChange, disabled }: TakeProps) { ... } +export function QuestionGradingPanel({ answer, onScoreChange, onFeedbackChange }: GradeProps) { ... } +``` + +### 6.3 国际化就绪 + +翻译文件结构示例: + +```json +// messages/zh-CN/exam-homework.json +{ + "exam": { + "list": { "title": "考试列表", "create": "创建考试", "empty": "暂无考试" }, + "form": { "title": "考试标题", "subject": "科目", "grade": "年级", "difficulty": "难度" }, + "status": { "draft": "草稿", "published": "已发布", "archived": "已归档" }, + "actions": { "preview": "预览", "edit": "编辑", "build": "组卷", "publish": "发布", "duplicate": "复制", "delete": "删除" } + }, + "homework": { + "list": { "title": "作业列表", "create": "创建作业", "submissionRate": "提交率", "averageScore": "平均分", "overdue": "逾期" }, + "take": { "start": "开始作答", "submit": "提交作业", "saveAnswer": "保存答案", "confirmSubmit": "确认提交", "unansweredWarning": "您有 {{count}} 道题未作答" }, + "grade": { "summary": "批改摘要", "totalScore": "总分", "correct": "正确", "incorrect": "错误", "partial": "部分正确", "submitGrades": "提交成绩" } + }, + "proctoring": { + "mode": { "homework": "作业模式", "timed": "限时模式", "proctored": "监考模式" }, + "config": { "duration": "考试时长(分钟)", "shuffleQuestions": "题目乱序", "antiCheat": "启用防作弊监控" } + } +} +``` + +### 6.4 错误与边界处理 + +- 每个页面配套 `error.tsx`(React Error Boundary)。 +- 每个页面配套 `loading.tsx`(骨架屏)。 +- `ExamHomeworkErrorBoundary` 组件区分 `NetworkError` / `PermissionDenied` / `NotFound` 三种状态。 + +### 6.5 可测试性 + +- 纯逻辑函数(`applyAutoGrades` / `computeIsCorrect` / `normalizeStructure`)已与 UI 分离,补充单测。 +- 数据获取逻辑通过 `ServicePort` 接口可 mock。 +- 新增 `__tests__/exam-homework-service.test.ts` 覆盖权限校验与数据流转。 + +### 6.6 可扩展性 + +配置驱动设计: + +```typescript +// modules/exam-homework/config/role-config.ts +export const EXAM_HOMEWORK_ROLE_CONFIG: Record = { + admin: { widgets: ['stats', 'all-exams', 'all-homework'], canGrade: false }, + teacher: { widgets: ['my-exams', 'my-homework', 'grading-queue'], canGrade: true }, + parent: { widgets: ['child-exam-results', 'child-homework-summary'], canGrade: false }, + student: { widgets: ['pending-exams', 'pending-homework', 'results'], canGrade: false }, +} +``` + +### 6.7 企业级补充 + +- **a11y**:颜色指示器增加 `sr-only` 文本;`aria-pressed` / `aria-label` 全覆盖。 +- **性能**:RSC 获取初始数据(已实现);客户端组件仅负责交互(已实现);流式渲染(`Suspense` 已部分使用)。 +- **安全**:data-access 层结合 `dataScope` 过滤(已实现);Server Action 二次校验(P0 待补全)。 +- **监控**:预留 `trackExamEvent(eventName, payload)` 接口,关键操作(创建/提交/批改)埋点。 diff --git a/src/app/(dashboard)/student/learning/assignments/[assignmentId]/error.tsx b/src/app/(dashboard)/student/learning/assignments/[assignmentId]/error.tsx new file mode 100644 index 0000000..c0f6310 --- /dev/null +++ b/src/app/(dashboard)/student/learning/assignments/[assignmentId]/error.tsx @@ -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 StudentAssignmentDetailError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations("examHomework") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/student/learning/assignments/page.tsx b/src/app/(dashboard)/student/learning/assignments/page.tsx index 518506d..cb6375a 100644 --- a/src/app/(dashboard)/student/learning/assignments/page.tsx +++ b/src/app/(dashboard)/student/learning/assignments/page.tsx @@ -1,58 +1,42 @@ import Link from "next/link" +import { getTranslations } from "next-intl/server" import { EmptyState } from "@/shared/components/ui/empty-state" -import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" -import { formatDate } from "@/shared/lib/utils" +import { StatusBadge } from "@/shared/components/ui/status-badge" +import { formatDate, cn } from "@/shared/lib/utils" import { getStudentHomeworkAssignments } from "@/modules/homework/data-access" import { getCurrentStudentUser } from "@/modules/users/data-access" -import { Inbox, UserX } from "lucide-react" +import { AssignmentFilters } from "@/modules/homework/components/assignment-filters" +import { Inbox, UserX, TriangleAlert } from "lucide-react" import type { StudentHomeworkAssignmentListItem, StudentHomeworkProgressStatus, } from "@/modules/homework/types" +import { + STUDENT_HOMEWORK_PROGRESS_VARIANT, +} from "@/modules/homework/types" export const dynamic = "force-dynamic" -const getStatusVariant = ( - status: StudentHomeworkProgressStatus -): "default" | "secondary" | "outline" => { - switch (status) { - case "graded": - return "default" - case "submitted": - return "secondary" - case "in_progress": - return "outline" - default: - return "outline" - } +type SearchParams = { [key: string]: string | string[] | undefined } + +const getParam = (params: SearchParams, key: string) => { + const v = params[key] + return Array.isArray(v) ? v[0] : v } -const getStatusLabel = (status: StudentHomeworkProgressStatus): string => { +const getActionLabel = (status: StudentHomeworkProgressStatus, t: (key: string) => string): string => { switch (status) { case "graded": - return "Graded" + return t("homework.review.title") case "submitted": - return "Submitted" + return t("common.view") case "in_progress": - return "In progress" + return t("common.continue") default: - return "Not started" - } -} - -const getActionLabel = (status: StudentHomeworkProgressStatus): string => { - switch (status) { - case "graded": - return "Review" - case "submitted": - return "View" - case "in_progress": - return "Continue" - default: - return "Start" + return t("homework.take.startAssignment") } } @@ -65,7 +49,55 @@ const getActionVariant = ( const isAnswered = (status: StudentHomeworkProgressStatus): boolean => status === "submitted" || status === "graded" -function AssignmentCard({ assignment: a }: { assignment: StudentHomeworkAssignmentListItem }) { +const matchesStatusFilter = ( + status: StudentHomeworkProgressStatus, + filter: string +): boolean => { + if (filter === "all") return true + if (filter === "pending") return status === "not_started" || status === "in_progress" + if (filter === "submitted") return status === "submitted" + if (filter === "graded") return status === "graded" + return true +} + +// Stable color mapping for subjects (hash-based, deterministic) +const SUBJECT_DOT_COLORS = [ + "bg-blue-500", + "bg-emerald-500", + "bg-amber-500", + "bg-violet-500", + "bg-rose-500", + "bg-cyan-500", + "bg-orange-500", + "bg-pink-500", + "bg-indigo-500", + "bg-teal-500", +] + +const getSubjectColor = (subject: string): string => { + let hash = 0 + for (let i = 0; i < subject.length; i++) { + hash = (hash * 31 + subject.charCodeAt(i)) | 0 + } + const idx = Math.abs(hash) % SUBJECT_DOT_COLORS.length + return SUBJECT_DOT_COLORS[idx] +} + +type TranslationFn = (key: string) => string + +function AssignmentCard({ + assignment: a, + t, + statusLabelMap, +}: { + assignment: StudentHomeworkAssignmentListItem + t: TranslationFn + statusLabelMap: Record +}) { + const now = new Date() + const isOverdue = a.dueAt ? new Date(a.dueAt) < now : false + const showOverdueBadge = isOverdue && !isAnswered(a.progressStatus) + return ( @@ -75,28 +107,38 @@ function AssignmentCard({ assignment: a }: { assignment: StudentHomeworkAssignme {a.title} - - {getStatusLabel(a.progressStatus)} - + -
- Due {a.dueAt ? formatDate(a.dueAt) : "-"} - - - Attempts {a.attemptsUsed}/{a.maxAttempts} - +
+ {t("homework.list.columns.dueAt")} {a.dueAt ? formatDate(a.dueAt) : "-"} + + {t("homework.take.attempts")} {a.attemptsUsed}/{a.maxAttempts} + {showOverdueBadge && ( + <> + + + + {t("homework.take.overdue")} + + + )}
-
Score
+
{t("homework.grade.score")}
{a.latestScore ?? "-"}
@@ -104,19 +146,53 @@ function AssignmentCard({ assignment: a }: { assignment: StudentHomeworkAssignme ) } -export default async function StudentAssignmentsPage() { +export default async function StudentAssignmentsPage({ + searchParams, +}: { + searchParams: Promise +}) { + const t = await getTranslations("examHomework") const student = await getCurrentStudentUser() + const statusLabelMap: Record = { + not_started: t("homework.status.not_started"), + in_progress: t("homework.status.in_progress"), + submitted: t("homework.status.submitted"), + graded: t("homework.status.graded"), + } + if (!student) { return ( - +
+
+

{t("homework.list.title")}

+

{t("homework.list.description")}

+
+ +
) } - const assignments = await getStudentHomeworkAssignments(student.id) + const [sp, assignments] = await Promise.all([ + searchParams, + getStudentHomeworkAssignments(student.id), + ]) + + const q = (getParam(sp, "q") || "").toLowerCase().trim() + const statusFilter = getParam(sp, "status") || "all" + + // 应用筛选 + const filtered = assignments.filter((a) => { + if (q && !a.title.toLowerCase().includes(q)) return false + if (!matchesStatusFilter(a.progressStatus, statusFilter)) return false + return true + }) + const hasAssignments = assignments.length > 0 - const assignmentsBySubject = assignments.reduce((acc, assignment) => { - const subject = assignment.subjectName?.trim() || "Other" + const hasFiltered = filtered.length > 0 + + const assignmentsBySubject = filtered.reduce((acc, assignment) => { + const subject = assignment.subjectName?.trim() || t("common.other") const existing = acc.get(subject) if (existing) { existing.push(assignment) @@ -130,9 +206,22 @@ export default async function StudentAssignmentsPage() { ) return ( - <> +
+
+

{t("homework.list.title")}

+

{t("homework.list.description")}

+
+ + {hasAssignments && } + {!hasAssignments ? ( - + + ) : !hasFiltered ? ( + ) : (
{subjectEntries.map(([subject, items]) => { @@ -149,15 +238,24 @@ export default async function StudentAssignmentsPage() { return (
-
{subject}
+
+
{unanswered.length > 0 && (
- Pending + {t("homework.status.not_started")}
{unanswered.map((a) => ( - + ))}
@@ -165,11 +263,11 @@ export default async function StudentAssignmentsPage() { {answered.length > 0 && (
- Completed + {t("common.completed")}
{answered.map((a) => ( - + ))}
@@ -179,6 +277,6 @@ export default async function StudentAssignmentsPage() { })}
)} - +
) } diff --git a/src/app/(dashboard)/teacher/exams/[id]/build/error.tsx b/src/app/(dashboard)/teacher/exams/[id]/build/error.tsx new file mode 100644 index 0000000..b7ff242 --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/[id]/build/error.tsx @@ -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 BuildExamError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations("examHomework") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/exams/[id]/build/loading.tsx b/src/app/(dashboard)/teacher/exams/[id]/build/loading.tsx new file mode 100644 index 0000000..3a5b5a9 --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/[id]/build/loading.tsx @@ -0,0 +1,16 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+
+ + +
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/exams/[id]/proctoring/error.tsx b/src/app/(dashboard)/teacher/exams/[id]/proctoring/error.tsx new file mode 100644 index 0000000..924ce45 --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/[id]/proctoring/error.tsx @@ -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 ProctoringError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations("examHomework") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/exams/[id]/proctoring/loading.tsx b/src/app/(dashboard)/teacher/exams/[id]/proctoring/loading.tsx new file mode 100644 index 0000000..706185d --- /dev/null +++ b/src/app/(dashboard)/teacher/exams/[id]/proctoring/loading.tsx @@ -0,0 +1,18 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+
+ + + +
+ +
+ ) +} diff --git a/src/app/(dashboard)/teacher/exams/all/page.tsx b/src/app/(dashboard)/teacher/exams/all/page.tsx index 88ae701..dac1871 100644 --- a/src/app/(dashboard)/teacher/exams/all/page.tsx +++ b/src/app/(dashboard)/teacher/exams/all/page.tsx @@ -1,12 +1,12 @@ import type { JSX } from "react" import { Suspense } from "react" import Link from "next/link" +import { getTranslations } from "next-intl/server" import { Button } from "@/shared/components/ui/button" import { Badge } from "@/shared/components/ui/badge" import { EmptyState } from "@/shared/components/ui/empty-state" import { Skeleton } from "@/shared/components/ui/skeleton" import { ExamDataTable } from "@/modules/exams/components/exam-data-table" -import { examColumns } from "@/modules/exams/components/exam-columns" import { ExamFilters } from "@/modules/exams/components/exam-filters" import { getExams } from "@/modules/exams/data-access" import { getAuthContext } from "@/shared/lib/auth-guard" @@ -16,6 +16,7 @@ import { FileText, PlusCircle } from "lucide-react" async function ExamsResults({ searchParams }: { searchParams: Promise }): Promise { const params = await searchParams const { dataScope } = await getAuthContext() + const t = await getTranslations("examHomework") const q = getParam(params, "q") const status = getParam(params, "status") @@ -45,20 +46,20 @@ async function ExamsResults({ searchParams }: { searchParams: Promise
- Showing + {t("exam.list.showing")} {counts.total} - exams + {t("exam.list.examsUnit")} - Draft {counts.draft} + {t("exam.status.draft")} {counts.draft} - Published {counts.published} - Archived {counts.archived} + {t("exam.status.published")} {counts.published} + {t("exam.status.archived")} {counts.archived}
@@ -67,27 +68,27 @@ async function ExamsResults({ searchParams }: { searchParams: Promise ) : ( - + )}
) diff --git a/src/app/(dashboard)/teacher/homework/assignments/[id]/error.tsx b/src/app/(dashboard)/teacher/homework/assignments/[id]/error.tsx new file mode 100644 index 0000000..142fab0 --- /dev/null +++ b/src/app/(dashboard)/teacher/homework/assignments/[id]/error.tsx @@ -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 AssignmentDetailError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations("examHomework") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/homework/assignments/[id]/loading.tsx b/src/app/(dashboard)/teacher/homework/assignments/[id]/loading.tsx new file mode 100644 index 0000000..95e2a4e --- /dev/null +++ b/src/app/(dashboard)/teacher/homework/assignments/[id]/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+
+
+ + + + +
+
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/error.tsx b/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/error.tsx new file mode 100644 index 0000000..ec9876d --- /dev/null +++ b/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/error.tsx @@ -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 SubmissionsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations("examHomework") + return ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/loading.tsx b/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/loading.tsx new file mode 100644 index 0000000..bd921f6 --- /dev/null +++ b/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+ + +
+
+
+ + {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx b/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx index 7d9b188..735359b 100644 --- a/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx +++ b/src/app/(dashboard)/teacher/homework/assignments/[id]/submissions/page.tsx @@ -1,6 +1,7 @@ import type { JSX } from "react" import Link from "next/link" import { notFound } from "next/navigation" +import { getTranslations } from "next-intl/server" import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" import { @@ -18,6 +19,7 @@ export const dynamic = "force-dynamic" export default async function HomeworkAssignmentSubmissionsPage({ params }: { params: Promise<{ id: string }> }): Promise { const { id } = await params + const t = await getTranslations("examHomework") const [assignment, submissions] = await Promise.all([ getHomeworkAssignmentById(id), getHomeworkSubmissions({ assignmentId: id }), @@ -28,24 +30,24 @@ export default async function HomeworkAssignmentSubmissionsPage({ params }: { pa
-

Submissions

+

{t("homework.grade.submissions")}

{assignment.title}

- Exam: {assignment.sourceExamTitle} + {t("homework.grade.exam")}: {assignment.sourceExamTitle} - Targets: {assignment.targetCount} + {t("homework.grade.targets")}: {assignment.targetCount} - Submitted: {assignment.submittedCount} + {t("homework.grade.submittedCount")}: {assignment.submittedCount} - Graded: {assignment.gradedCount} + {t("homework.grade.gradedCount")}: {assignment.gradedCount}
@@ -54,11 +56,11 @@ export default async function HomeworkAssignmentSubmissionsPage({ params }: { pa - Student - Status - Submitted - Score - Action + {t("homework.grade.student")} + {t("homework.grade.status")} + {t("homework.grade.submitted")} + {t("homework.grade.score")} + {t("homework.grade.action")} @@ -69,13 +71,13 @@ export default async function HomeworkAssignmentSubmissionsPage({ params }: { pa {s.status} - {s.isLate ? Late : null} + {s.isLate ? {t("homework.grade.late")} : null} {s.submittedAt ? formatDate(s.submittedAt) : "-"} {typeof s.score === "number" ? s.score : "-"} - Grade + {t("homework.grade.title")} diff --git a/src/app/(dashboard)/teacher/homework/assignments/create/loading.tsx b/src/app/(dashboard)/teacher/homework/assignments/create/loading.tsx new file mode 100644 index 0000000..7f1a2b2 --- /dev/null +++ b/src/app/(dashboard)/teacher/homework/assignments/create/loading.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function Loading() { + return ( +
+
+
+ + +
+ +
+
+ ) +} diff --git a/src/app/(dashboard)/teacher/homework/assignments/page.tsx b/src/app/(dashboard)/teacher/homework/assignments/page.tsx index 92ec7c7..a40f450 100644 --- a/src/app/(dashboard)/teacher/homework/assignments/page.tsx +++ b/src/app/(dashboard)/teacher/homework/assignments/page.tsx @@ -11,16 +11,22 @@ import { TableHeader, TableRow, } from "@/shared/components/ui/table" -import { formatDate } from "@/shared/lib/utils" +import { Progress } from "@/shared/components/ui/progress" +import { ListPagination, computePagination, paginate } from "@/shared/components/ui/list-pagination" +import { formatDate, formatNumber } from "@/shared/lib/utils" import { getParam, type SearchParams } from "@/shared/lib/search-params" import { getHomeworkAssignments } from "@/modules/homework/data-access" import { getTeacherClasses } from "@/modules/classes/data-access" -import { PenTool, PlusCircle } from "lucide-react" +import { PenTool, PlusCircle, AlertCircle } from "lucide-react" import { getTeacherIdForMutations } from "@/modules/classes/data-access" +import { getTranslations } from "next-intl/server" export const dynamic = "force-dynamic" +const PAGE_SIZE = 10 + export default async function AssignmentsPage({ searchParams }: { searchParams: Promise }): Promise { + const t = await getTranslations("examHomework") const sp = await searchParams const rawClassId = getParam(sp, "classId") const creatorId = await getTeacherIdForMutations() @@ -36,19 +42,26 @@ export default async function AssignmentsPage({ searchParams }: { searchParams: const hasAssignments = assignments.length > 0 const className = filteredClassId ? classes.find((c) => c.id === filteredClassId)?.name : undefined + // 分页计算 + const { page } = computePagination(sp, PAGE_SIZE) + const total = assignments.length + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + const currentPage = Math.min(page, totalPages) + const pagedAssignments = paginate(assignments, currentPage, PAGE_SIZE) + return (
-

Assignments

+

{t("homework.list.title")}

- {filteredClassId ? `Filtered by class: ${className ?? filteredClassId}` : "Manage homework assignments."} + {filteredClassId ? t("homework.list.filterByClass", { className: className ?? filteredClassId }) : t("homework.list.description")}

{filteredClassId ? ( ) : null}
@@ -68,11 +81,11 @@ export default async function AssignmentsPage({ searchParams }: { searchParams: {!hasAssignments ? ( - Title - Status - Due - Source Exam - Created + {t("homework.list.columns.title")} + {t("homework.list.columns.status")} + {t("homework.list.columns.dueAt")} + {t("homework.list.columns.submissionRate")} + {t("homework.list.columns.averageScore")} + {t("homework.list.columns.overdue")} + {t("homework.list.columns.sourceExam")} + {t("homework.list.columns.createdAt")} - {assignments.map((a) => ( - - - - {a.title} - - - - - {a.status} - - - {a.dueAt ? formatDate(a.dueAt) : "-"} - {a.sourceExamTitle} - {formatDate(a.createdAt)} - - ))} + {pagedAssignments.map((a) => { + const submissionRate = a.targetCount > 0 ? (a.submittedCount / a.targetCount) * 100 : 0 + const hasOverdue = a.overdueCount > 0 + return ( + + + + {a.title} + + + + + {t(`homework.status.${a.status}`)} + + + {a.dueAt ? formatDate(a.dueAt) : "-"} + +
+ + + {a.submittedCount}/{a.targetCount} + +
+
+ + {a.averageScore !== null ? formatNumber(a.averageScore, 1) : "-"} + + + {hasOverdue ? ( + + + ) : ( + 0 + )} + + {a.sourceExamTitle} + {formatDate(a.createdAt)} +
+ ) + })}
+
)}
diff --git a/src/i18n/request.ts b/src/i18n/request.ts index 1ed7ad0..fc212d8 100644 --- a/src/i18n/request.ts +++ b/src/i18n/request.ts @@ -35,6 +35,8 @@ export default getRequestConfig(async () => { lessonPreparation, grades, diagnostic, + attendance, + elective, ] = await Promise.all([ import(`@/shared/i18n/messages/${locale}/common.json`), import(`@/shared/i18n/messages/${locale}/auth.json`), @@ -51,6 +53,8 @@ export default getRequestConfig(async () => { import(`@/shared/i18n/messages/${locale}/lesson-preparation.json`), import(`@/shared/i18n/messages/${locale}/grades.json`), import(`@/shared/i18n/messages/${locale}/diagnostic.json`), + import(`@/shared/i18n/messages/${locale}/attendance.json`), + import(`@/shared/i18n/messages/${locale}/elective.json`), ]); return { @@ -71,6 +75,8 @@ export default getRequestConfig(async () => { lessonPreparation: lessonPreparation.default, grades: grades.default, diagnostic: diagnostic.default, + attendance: attendance.default, + elective: elective.default, }, }; }); diff --git a/src/modules/exams/components/exam-actions.tsx b/src/modules/exams/components/exam-actions.tsx index f3ffffa..9ea8c92 100644 --- a/src/modules/exams/components/exam-actions.tsx +++ b/src/modules/exams/components/exam-actions.tsx @@ -2,6 +2,7 @@ import { useState } from "react" import { useRouter } from "next/navigation" +import { useTranslations } from "next-intl" import { MoreHorizontal, Eye, Pencil, Trash, Archive, UploadCloud, Undo2, Copy } from "lucide-react" import { toast } from "sonner" @@ -43,6 +44,7 @@ interface ExamActionsProps { export function ExamActions({ exam }: ExamActionsProps) { const router = useRouter() + const t = useTranslations("examHomework") const [showViewDialog, setShowViewDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isWorking, setIsWorking] = useState(false) @@ -76,11 +78,11 @@ export function ExamActions({ exam }: ExamActionsProps) { const nodes = Array.isArray(structure) ? hydrate(structure) : [] setPreviewNodes(nodes) } else { - toast.error("Failed to load exam preview") + toast.error(t("exam.actions.previewFailed")) setShowViewDialog(false) } } catch { - toast.error("Failed to load exam preview") + toast.error(t("exam.actions.previewFailed")) setShowViewDialog(false) } finally { setLoadingPreview(false) @@ -89,7 +91,7 @@ export function ExamActions({ exam }: ExamActionsProps) { const copyId = () => { navigator.clipboard.writeText(exam.id) - toast.success("Exam ID copied to clipboard") + toast.success(t("exam.actions.idCopied")) } const setStatus = async (status: Exam["status"]) => { @@ -100,13 +102,13 @@ export function ExamActions({ exam }: ExamActionsProps) { formData.set("status", status) const result = await updateExamAction(null, formData) if (result.success) { - toast.success(status === "published" ? "Exam published" : status === "archived" ? "Exam archived" : "Exam moved to draft") + toast.success(status === "published" ? t("exam.actions.publishSuccess") : status === "archived" ? t("exam.actions.archiveSuccess") : t("exam.actions.draftSuccess")) router.refresh() } else { - toast.error(result.message || "Failed to update exam") + toast.error(result.message || t("exam.actions.updateFailed")) } } catch { - toast.error("Failed to update exam") + toast.error(t("exam.actions.updateFailed")) } finally { setIsWorking(false) } @@ -119,14 +121,14 @@ export function ExamActions({ exam }: ExamActionsProps) { formData.set("examId", exam.id) const result = await duplicateExamAction(null, formData) if (result.success && result.data) { - toast.success("Exam duplicated") + toast.success(t("exam.actions.duplicateSuccess")) router.push(`/teacher/exams/${result.data}/build`) router.refresh() } else { - toast.error(result.message || "Failed to duplicate exam") + toast.error(result.message || t("exam.actions.duplicateFailed")) } } catch { - toast.error("Failed to duplicate exam") + toast.error(t("exam.actions.duplicateFailed")) } finally { setIsWorking(false) } @@ -139,14 +141,14 @@ export function ExamActions({ exam }: ExamActionsProps) { formData.set("examId", exam.id) const result = await deleteExamAction(null, formData) if (result.success) { - toast.success("Exam deleted successfully") + toast.success(t("exam.actions.deleteSuccess")) setShowDeleteDialog(false) router.refresh() } else { - toast.error(result.message || "Failed to delete exam") + toast.error(result.message || t("exam.actions.deleteFailed")) } } catch { - toast.error("Failed to delete exam") + toast.error(t("exam.actions.deleteFailed")) } finally { setIsWorking(false) } @@ -163,75 +165,74 @@ export function ExamActions({ exam }: ExamActionsProps) { e.stopPropagation() handleView() }} - title="Preview Exam" - aria-label="Preview exam" + title={t("exam.actions.preview")} + aria-label={t("exam.actions.preview")} > - - Actions + {t("exam.actions.copyId")} - Copy ID + {t("exam.actions.copyId")} router.push(`/teacher/exams/${exam.id}/build`)}> - Edit + {t("exam.actions.edit")} router.push(`/teacher/exams/${exam.id}/build`)}> - Build + {t("exam.actions.build")} - Duplicate + {t("exam.actions.duplicate")} setStatus("published")} disabled={isWorking || exam.status === "published"} > - Publish + {t("exam.actions.publish")} setStatus("draft")} disabled={isWorking || exam.status === "draft"} > - Move to Draft + {t("exam.actions.moveToDraft")} setStatus("archived")} disabled={isWorking || exam.status === "archived"} > - Archive + {t("exam.actions.archive")} setShowDeleteDialog(true)} disabled={isWorking} > - Delete + {t("exam.actions.delete")}
- + - Are you absolutely sure? + {t("exam.actions.deleteConfirmTitle")} - This action cannot be undone. This will permanently delete the exam - "{exam.title}" and remove all associated data. + {t("exam.actions.deleteConfirmDescription", { title: exam.title })} - Cancel + {t("exam.actions.cancel")} { @@ -240,7 +241,7 @@ export function ExamActions({ exam }: ExamActionsProps) { }} disabled={isWorking} > - Delete + {t("exam.actions.delete")} @@ -253,10 +254,10 @@ export function ExamActions({ exam }: ExamActionsProps) { {loadingPreview ? ( -
Loading preview...
+
{t("exam.actions.loadingPreview")}
) : previewNodes && previewNodes.length > 0 ? (
- ) : (
- No questions in this exam. + {t("exam.actions.noQuestions")}
)} diff --git a/src/modules/exams/components/exam-columns.tsx b/src/modules/exams/components/exam-columns.tsx index 7de7509..f509b41 100644 --- a/src/modules/exams/components/exam-columns.tsx +++ b/src/modules/exams/components/exam-columns.tsx @@ -7,152 +7,154 @@ import { cn, formatDate } from "@/shared/lib/utils" import { Exam } from "../types" import { ExamActions } from "./exam-actions" -export const examColumns: ColumnDef[] = [ - { - id: "select", - header: ({ table }) => ( - table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - /> - ), - cell: ({ row }) => ( - row.toggleSelected(!!value)} - aria-label="Select row" - /> - ), - enableSorting: false, - enableHiding: false, - size: 36, - }, - { - accessorKey: "title", - header: "Exam Info", - cell: ({ row }) => ( -
-
- {row.original.title} - {row.original.tags && row.original.tags.length > 0 && ( -
- {row.original.tags.slice(0, 2).map((t, idx) => ( - - {t} - - ))} - {row.original.tags.length > 2 && ( - +{row.original.tags.length - 2} - )} -
- )} -
-
- {row.original.subject} - - {row.original.grade} -
-
- ), - }, - { - accessorKey: "status", - header: "Status", - cell: ({ row }) => { - const status = row.original.status - // Use 'default' as base for published/success to ensure type safety, - // but override with className below - const variant: BadgeProps["variant"] = - status === "published" - ? "default" - : status === "archived" - ? "secondary" - : "outline" - - return ( - - {status} - - ) +type TranslationFn = (key: string, params?: Record) => string + +export function createExamColumns(t: TranslationFn): ColumnDef[] { + return [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label={t("exam.actions.selectAll")} + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label={t("exam.actions.selectRow")} + /> + ), + enableSorting: false, + enableHiding: false, + size: 36, }, - }, - { - id: "stats", - header: "Stats", - cell: ({ row }) => ( -
-
- {row.original.questionCount} Qs - - {row.original.totalScore} Pts -
-
- {row.original.durationMin} min -
-
- ), - }, - { - accessorKey: "difficulty", - header: "Difficulty", - cell: ({ row }) => { - const diff = row.original.difficulty - return ( + { + accessorKey: "title", + header: t("exam.columns.examInfo"), + cell: ({ row }) => (
-
- {[1, 2, 3, 4, 5].map((level) => ( -
+ {row.original.title} + {row.original.tags && row.original.tags.length > 0 && ( +
+ {row.original.tags.slice(0, 2).map((tag, idx) => ( + + {tag} + + ))} + {row.original.tags.length > 2 && ( + +{row.original.tags.length - 2} )} - /> - ))} +
+ )} +
+
+ {row.original.subject} + + {row.original.grade}
- - {diff === 1 ? "Easy" : diff === 2 ? "Easy-Med" : diff === 3 ? "Medium" : diff === 4 ? "Med-Hard" : "Hard"} -
- ) + ), }, - }, - { - id: "dates", - header: "Date", - cell: ({ row }) => { - const scheduled = row.original.scheduledAt - const created = row.original.createdAt - - return ( -
- {scheduled ? ( - <> - Scheduled - {formatDate(scheduled)} - - ) : ( - <> - Created - {formatDate(created)} - - )} + { + accessorKey: "status", + header: t("exam.columns.status"), + cell: ({ row }) => { + const status = row.original.status + const variant: BadgeProps["variant"] = + status === "published" + ? "default" + : status === "archived" + ? "secondary" + : "outline" + + return ( + + {t(`exam.status.${status}`)} + + ) + }, + }, + { + id: "stats", + header: t("exam.columns.stats"), + cell: ({ row }) => ( +
+
+ {row.original.questionCount} {t("exam.columns.questions")} + + {row.original.totalScore} {t("exam.columns.points")} +
+
+ {row.original.durationMin} min +
- ) + ), }, - }, - { - id: "actions", - cell: ({ row }) => , - }, -] + { + accessorKey: "difficulty", + header: t("exam.columns.difficulty"), + cell: ({ row }) => { + const diff = row.original.difficulty + return ( +
+
+ {[1, 2, 3, 4, 5].map((level) => ( +
+ ))} +
+ + {t(`exam.difficulty.${diff}`)} + +
+ ) + }, + }, + { + id: "dates", + header: t("exam.columns.date"), + cell: ({ row }) => { + const scheduled = row.original.scheduledAt + const created = row.original.createdAt + + return ( +
+ {scheduled ? ( + <> + {t("exam.columns.scheduled")} + {formatDate(scheduled)} + + ) : ( + <> + {t("exam.columns.created")} + {formatDate(created)} + + )} +
+ ) + }, + }, + { + id: "actions", + cell: ({ row }) => , + }, + ] +} diff --git a/src/modules/exams/components/exam-data-table.tsx b/src/modules/exams/components/exam-data-table.tsx index 88a95e8..d026acd 100644 --- a/src/modules/exams/components/exam-data-table.tsx +++ b/src/modules/exams/components/exam-data-table.tsx @@ -2,7 +2,6 @@ import * as React from "react" import { - ColumnDef, flexRender, getCoreRowModel, useReactTable, @@ -12,6 +11,7 @@ import { getFilteredRowModel, RowSelectionState, } from "@tanstack/react-table" +import { useTranslations } from "next-intl" import { Table, @@ -23,16 +23,23 @@ import { } from "@/shared/components/ui/table" import { Button } from "@/shared/components/ui/button" import { ChevronLeft, ChevronRight } from "lucide-react" +import { Exam } from "../types" +import { createExamColumns } from "./exam-columns" -interface DataTableProps { - columns: ColumnDef[] - data: TData[] +interface DataTableProps { + data: Exam[] } -export function ExamDataTable({ columns, data }: DataTableProps) { +export function ExamDataTable({ data }: DataTableProps) { + const t = useTranslations("examHomework") const [sorting, setSorting] = React.useState([]) const [rowSelection, setRowSelection] = React.useState({}) + const columns = React.useMemo( + () => createExamColumns((key, params) => t(key, params as Record | undefined)), + [t] + ) + const table = useReactTable({ data, columns, @@ -81,7 +88,7 @@ export function ExamDataTable({ columns, data }: DataTableProps - No results. + {t("common.noResults")} )} @@ -90,14 +97,13 @@ export function ExamDataTable({ columns, data }: DataTableProps
- {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) - selected. + {table.getFilteredSelectedRowModel().rows.length} {t("common.of")} {table.getFilteredRowModel().rows.length} {t("common.rows")} {t("common.selected")}.
-

Page

+

{t("common.page")}

- {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} + {table.getState().pagination.pageIndex + 1} {t("common.of")} {table.getPageCount()}
@@ -107,7 +113,7 @@ export function ExamDataTable({ columns, data }: DataTableProps table.previousPage()} disabled={!table.getCanPreviousPage()} > - Go to previous page + {t("common.page")}
@@ -125,4 +131,3 @@ export function ExamDataTable({ columns, data }: DataTableProps ) } - diff --git a/src/modules/homework/actions.ts b/src/modules/homework/actions.ts index 99e4f0e..03ad5b7 100644 --- a/src/modules/homework/actions.ts +++ b/src/modules/homework/actions.ts @@ -13,6 +13,7 @@ import { getActiveClassStudentIdsForHomework, getClassTeacherById, getExamWithQuestionsForHomework, + getHomeworkSubmissionForGrading, getHomeworkSubmissionForPermission, getTeacherAssignedSubjectIds, gradeHomeworkAnswers, @@ -71,20 +72,29 @@ export async function createHomeworkAssignmentAction( const classRow = await getClassTeacherById(input.classId) if (!classRow) return { success: false, message: "Class not found" } - const exam = await getExamWithQuestionsForHomework(input.sourceExamId) - if (!exam) return { success: false, message: "Exam not found" } + // 快速作业模式:无 sourceExamId 时创建纯文本作业(无题目) + const isQuickAssignment = !input.sourceExamId + + let exam: Awaited> = null + if (!isQuickAssignment) { + const examData = await getExamWithQuestionsForHomework(input.sourceExamId!) + if (!examData) return { success: false, message: "Exam not found" } + exam = examData + } if (ctx.dataScope.type !== "all" && classRow.teacherId !== ctx.userId) { const assignedSubjectIds = await getTeacherAssignedSubjectIds(input.classId, ctx.userId) if (assignedSubjectIds.length === 0) { return { success: false, message: "Not assigned to this class" } } - const assignedSubjectSet = new Set(assignedSubjectIds) - if (!exam.subjectId) { - return { success: false, message: "Exam subject not set" } - } - if (!assignedSubjectSet.has(exam.subjectId)) { - return { success: false, message: "Not assigned to this subject" } + if (!isQuickAssignment && exam) { + const assignedSubjectSet = new Set(assignedSubjectIds) + if (!exam.subjectId) { + return { success: false, message: "Exam subject not set" } + } + if (!assignedSubjectSet.has(exam.subjectId)) { + return { success: false, message: "Not assigned to this subject" } + } } } @@ -112,10 +122,10 @@ export async function createHomeworkAssignmentAction( await createHomeworkAssignment({ assignmentId, - sourceExamId: input.sourceExamId, - title: input.title?.trim().length ? input.title.trim() : exam.title, + sourceExamId: input.sourceExamId ?? null, + title: input.title?.trim().length ? input.title.trim() : (exam?.title ?? "Untitled Assignment"), description: input.description ?? null, - structure: exam.structure, + structure: exam?.structure ?? [], status: publish ? "published" : "draft", creatorId: ctx.userId, availableAt, @@ -124,7 +134,7 @@ export async function createHomeworkAssignmentAction( lateDueAt, maxAttempts: input.maxAttempts ?? 1, publish, - questions: exam.questions, + questions: exam?.questions ?? [], targetStudentIds, }) @@ -242,7 +252,7 @@ export async function gradeHomeworkSubmissionAction( formData: FormData ): Promise> { try { - await requirePermission(Permissions.HOMEWORK_GRADE) + const ctx = await requirePermission(Permissions.HOMEWORK_GRADE) const rawAnswersValue = formData.get("answersJson") const rawAnswers = typeof rawAnswersValue === "string" ? rawAnswersValue : null @@ -261,6 +271,18 @@ export async function gradeHomeworkSubmissionAction( const { submissionId, answers } = parsed.data + // 权限二次校验:非管理员仅可批改自己创建的作业提交 + // 管理员(dataScope.type === "all")可批改所有提交 + if (ctx.dataScope.type !== "all") { + const submissionForGrading = await getHomeworkSubmissionForGrading(submissionId) + if (!submissionForGrading) { + return { success: false, message: "Submission not found" } + } + if (submissionForGrading.creatorId !== ctx.userId) { + return { success: false, message: "You can only grade submissions for your own assignments" } + } + } + await gradeHomeworkAnswers( submissionId, answers.map((ans) => ({ diff --git a/src/modules/homework/components/homework-assignment-form.tsx b/src/modules/homework/components/homework-assignment-form.tsx index e7496b5..02cf158 100644 --- a/src/modules/homework/components/homework-assignment-form.tsx +++ b/src/modules/homework/components/homework-assignment-form.tsx @@ -5,6 +5,8 @@ import { useFormStatus } from "react-dom" import { toast } from "sonner" import { useRouter } from "next/navigation" import { useSearchParams } from "next/navigation" +import { useTranslations } from "next-intl" +import { FileText, FileQuestion } from "lucide-react" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Button } from "@/shared/components/ui/button" @@ -12,6 +14,7 @@ import { Input } from "@/shared/components/ui/input" import { Label } from "@/shared/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" import { Textarea } from "@/shared/components/ui/textarea" +import { cn } from "@/shared/lib/utils" import { createHomeworkAssignmentAction } from "../actions" import type { TeacherClass } from "@/modules/classes/types" @@ -20,9 +23,10 @@ type ExamOption = { id: string; title: string } function SubmitButton() { const { pending } = useFormStatus() + const t = useTranslations("examHomework") return ( ) } @@ -30,7 +34,9 @@ function SubmitButton() { export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]; classes: TeacherClass[] }) { const router = useRouter() const searchParams = useSearchParams() + const t = useTranslations("examHomework") + const [mode, setMode] = useState<"exam" | "quick">(exams.length > 0 ? "exam" : "quick") const initialExamId = useMemo(() => exams[0]?.id ?? "", [exams]) const [examId, setExamId] = useState(initialExamId) const initialClassId = useMemo(() => { @@ -40,43 +46,100 @@ export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[] }, [classes, searchParams]) const [classId, setClassId] = useState(initialClassId) const [allowLate, setAllowLate] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) const handleSubmit = async (formData: FormData) => { - if (!examId) { - toast.error("Please select an exam") + if (mode === "exam" && !examId) { + toast.error(t("homework.form.selectExamRequired")) + return + } + if (mode === "quick" && !formData.get("title")) { + toast.error(t("homework.form.titleRequired")) return } if (!classId) { - toast.error("Please select a class") + toast.error(t("homework.form.selectClassRequired")) return } - formData.set("sourceExamId", examId) + + if (mode === "exam") { + formData.set("sourceExamId", examId) + } else { + formData.delete("sourceExamId") + } formData.set("classId", classId) formData.set("allowLate", allowLate ? "true" : "false") formData.set("publish", "true") + setIsSubmitting(true) const result = await createHomeworkAssignmentAction(null, formData) + setIsSubmitting(false) if (result.success) { toast.success(result.message) router.push("/teacher/homework/assignments") } else { - toast.error(result.message || "Failed to create") + toast.error(result.message || t("homework.form.createFailed")) } } return ( - + + {isSubmitting && ( +
+
+
+ {t("homework.form.creating")} +
+
+ )} - Create Assignment + {t("homework.form.createTitle")}
+ {/* 模式切换 */} +
+ + +
+
- +
+ {mode === "exam" && ( +
+ + + +
+ )} +
- - - + +
- - -
- -
- -