feat(exam-homework): add audit report, i18n, error boundaries, and permission hardening

- Add comprehensive audit report for exam and homework module

- Create exam-homework i18n message files (zh-CN + en) and register namespace

- Add permission check to gradeHomeworkSubmissionAction to prevent horizontal privilege escalation

- Add Error Boundary + loading.tsx for 5 key pages (exam build/proctoring, homework assignment/submissions, student assignment)

- Refactor exam-columns to createExamColumns(t) factory for i18n support

- Refactor exam-data-table to manage columns internally via useTranslations

- Replace hardcoded strings with i18n keys in all exam/homework components and pages

- Add getHomeworkSubmissionForGrading data-access for secure grading flow
This commit is contained in:
SpecialX
2026-06-22 16:08:39 +08:00
parent fde711ce46
commit 21c7e65fee
26 changed files with 2059 additions and 463 deletions

View File

@@ -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<ExamListItem[]>
getExamById(id: string): Promise<ExamDetail | null>
getHomeworkAssignments(scope: DataScope): Promise<HomeworkAssignmentListItem[]>
getStudentHomeworkTakeData(assignmentId: string, studentId: string): Promise<StudentHomeworkTakeData | null>
// ... 其余数据访问方法
}
export interface ExamHomeworkPermissionPort {
canGradeSubmission(teacherId: string, submissionId: string): Promise<boolean>
canViewExam(userId: string, examId: string, scope: DataScope): Promise<boolean>
}
```
通过 `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<Role, ExamHomeworkRoleConfig> = {
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)` 接口,关键操作(创建/提交/批改)埋点。

View 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 StudentAssignmentDetailError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
const t = useTranslations("examHomework")
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title={t("homework.error.notFound")}
description={t("homework.error.assignmentNotFound")}
action={{
label: t("common.retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -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<StudentHomeworkProgressStatus, string>
}) {
const now = new Date()
const isOverdue = a.dueAt ? new Date(a.dueAt) < now : false
const showOverdueBadge = isOverdue && !isAnswered(a.progressStatus)
return (
<Card className="flex h-full flex-col overflow-hidden transition-all hover:shadow-md">
<CardHeader className="gap-2 pb-3">
@@ -75,28 +107,38 @@ function AssignmentCard({ assignment: a }: { assignment: StudentHomeworkAssignme
{a.title}
</Link>
</CardTitle>
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
{getStatusLabel(a.progressStatus)}
</Badge>
<StatusBadge
status={a.progressStatus}
variantMap={STUDENT_HOMEWORK_PROGRESS_VARIANT}
labelMap={statusLabelMap}
/>
</div>
<div className="text-xs text-muted-foreground">
<span>Due {a.dueAt ? formatDate(a.dueAt) : "-"}</span>
<span className="px-2" aria-hidden="true">
</span>
<span>
Attempts {a.attemptsUsed}/{a.maxAttempts}
<div className={cn(
"text-xs",
showOverdueBadge ? "text-destructive font-medium" : "text-muted-foreground"
)}>
<span>{t("homework.list.columns.dueAt")} {a.dueAt ? formatDate(a.dueAt) : "-"}</span>
<span className="px-2" aria-hidden="true"></span>
<span>{t("homework.take.attempts")} {a.attemptsUsed}/{a.maxAttempts}</span>
{showOverdueBadge && (
<>
<span className="px-2" aria-hidden="true"></span>
<span className="inline-flex items-center gap-1">
<TriangleAlert className="h-3 w-3" />
{t("homework.take.overdue")}
</span>
</>
)}
</div>
</CardHeader>
<CardContent className="mt-auto flex items-center justify-between">
<div className="text-sm">
<div className="text-muted-foreground">Score</div>
<div className="text-muted-foreground">{t("homework.grade.score")}</div>
<div className="font-medium tabular-nums">{a.latestScore ?? "-"}</div>
</div>
<Button asChild size="sm" variant={getActionVariant(a.progressStatus)}>
<Link href={`/student/learning/assignments/${a.id}`}>
{getActionLabel(a.progressStatus)}
{getActionLabel(a.progressStatus, t)}
</Link>
</Button>
</CardContent>
@@ -104,19 +146,53 @@ function AssignmentCard({ assignment: a }: { assignment: StudentHomeworkAssignme
)
}
export default async function StudentAssignmentsPage() {
export default async function StudentAssignmentsPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const t = await getTranslations("examHomework")
const student = await getCurrentStudentUser()
const statusLabelMap: Record<StudentHomeworkProgressStatus, string> = {
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 (
<EmptyState title="No user found" description="Create a student user to see assignments." icon={UserX} />
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t("homework.list.title")}</h2>
<p className="text-muted-foreground">{t("homework.list.description")}</p>
</div>
<EmptyState title={t("homework.error.notFound")} description={t("homework.error.assignmentNotFound")} icon={UserX} />
</div>
)
}
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 (
<>
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t("homework.list.title")}</h2>
<p className="text-muted-foreground">{t("homework.list.description")}</p>
</div>
{hasAssignments && <AssignmentFilters />}
{!hasAssignments ? (
<EmptyState title="No assignments" description="You have no assigned homework right now." icon={Inbox} />
<EmptyState title={t("homework.list.empty")} description={t("homework.list.emptyDescription")} icon={Inbox} />
) : !hasFiltered ? (
<EmptyState
title={t("homework.list.emptyFiltered")}
description={t("homework.list.emptyFilteredDescription")}
icon={Inbox}
/>
) : (
<div className="space-y-6">
{subjectEntries.map(([subject, items]) => {
@@ -149,15 +238,24 @@ export default async function StudentAssignmentsPage() {
return (
<div key={subject} className="space-y-3">
<div className="text-sm font-semibold text-muted-foreground">{subject}</div>
<div className="flex items-center gap-2 text-sm font-semibold">
<span
className={cn("h-2.5 w-2.5 rounded-full", getSubjectColor(subject))}
aria-hidden="true"
/>
<span className="text-muted-foreground">{subject}</span>
<span className="text-xs font-normal text-muted-foreground/70">
({items.length})
</span>
</div>
{unanswered.length > 0 && (
<div className="space-y-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Pending
{t("homework.status.not_started")}
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{unanswered.map((a) => (
<AssignmentCard key={a.id} assignment={a} />
<AssignmentCard key={a.id} assignment={a} t={t} statusLabelMap={statusLabelMap} />
))}
</div>
</div>
@@ -165,11 +263,11 @@ export default async function StudentAssignmentsPage() {
{answered.length > 0 && (
<div className="space-y-3">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Completed
{t("common.completed")}
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{answered.map((a) => (
<AssignmentCard key={a.id} assignment={a} />
<AssignmentCard key={a.id} assignment={a} t={t} statusLabelMap={statusLabelMap} />
))}
</div>
</div>
@@ -179,6 +277,6 @@ export default async function StudentAssignmentsPage() {
})}
</div>
)}
</>
</div>
)
}

View 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 BuildExamError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
const t = useTranslations("examHomework")
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title={t("exam.error.loadFailed")}
description={t("exam.error.notFound")}
action={{
label: t("common.retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,16 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-4 p-4">
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-72" />
</div>
<div className="grid w-full grid-cols-1 gap-4 lg:grid-cols-3">
<Skeleton className="h-[600px] lg:col-span-2" />
<Skeleton className="h-[600px] lg:col-span-1" />
</div>
</div>
)
}

View 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 ProctoringError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
const t = useTranslations("examHomework")
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title={t("exam.error.loadFailed")}
description={t("exam.error.notFound")}
action={{
label: t("common.retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,18 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-4 p-4">
<div className="space-y-2">
<Skeleton className="h-7 w-48" />
<Skeleton className="h-4 w-72" />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<Skeleton className="h-32" />
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
<Skeleton className="h-[500px] w-full" />
</div>
)
}

View File

@@ -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<SearchParams> }): Promise<JSX.Element> {
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<SearchPara
<div className="space-y-4">
<div className="flex flex-col gap-3 rounded-md border bg-card px-4 py-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">Showing</span>
<span className="text-sm text-muted-foreground">{t("exam.list.showing")}</span>
<span className="text-sm font-medium">{counts.total}</span>
<span className="text-sm text-muted-foreground">exams</span>
<span className="text-sm text-muted-foreground">{t("exam.list.examsUnit")}</span>
<Badge variant="outline" className="ml-0 md:ml-2">
Draft {counts.draft}
{t("exam.status.draft")} {counts.draft}
</Badge>
<Badge variant="outline">Published {counts.published}</Badge>
<Badge variant="outline">Archived {counts.archived}</Badge>
<Badge variant="outline">{t("exam.status.published")} {counts.published}</Badge>
<Badge variant="outline">{t("exam.status.archived")} {counts.archived}</Badge>
</div>
<div className="flex items-center gap-2">
<Button asChild size="sm">
<Link href="/teacher/exams/create" className="inline-flex items-center gap-2">
<PlusCircle className="h-4 w-4" aria-hidden="true" />
Create Exam
{t("exam.list.create")}
</Link>
</Button>
</div>
@@ -67,27 +68,27 @@ async function ExamsResults({ searchParams }: { searchParams: Promise<SearchPara
{exams.length === 0 ? (
<EmptyState
icon={FileText}
title={hasFilters ? "No exams match your filters" : "No exams yet"}
title={hasFilters ? t("exam.list.emptyFiltered") : t("exam.list.empty")}
description={
hasFilters
? "Try clearing filters or adjusting keywords."
: "Create your first exam to start assigning and grading."
? t("exam.list.emptyFilteredDescription")
: t("exam.list.emptyDescription")
}
action={
hasFilters
? {
label: "Clear filters",
label: t("exam.list.clearFilters"),
href: "/teacher/exams/all",
}
: {
label: "Create Exam",
label: t("exam.list.create"),
href: "/teacher/exams/create",
}
}
className="h-[360px] bg-card"
/>
) : (
<ExamDataTable columns={examColumns} data={exams} />
<ExamDataTable data={exams} />
)}
</div>
)

View 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 AssignmentDetailError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
const t = useTranslations("examHomework")
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title={t("homework.error.notFound")}
description={t("homework.error.assignmentNotFound")}
action={{
label: t("common.retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,20 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-7 w-48" />
<Skeleton className="h-4 w-72" />
</div>
<div className="rounded-md border bg-card">
<div className="p-4 space-y-3">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
</div>
</div>
)
}

View 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 SubmissionsError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
const t = useTranslations("examHomework")
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title={t("homework.error.notFound")}
description={t("homework.error.submissionNotFound")}
action={{
label: t("common.retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,20 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-72" />
</div>
<div className="rounded-md border bg-card">
<div className="p-4 space-y-3">
<Skeleton className="h-8 w-full" />
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</div>
</div>
)
}

View File

@@ -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<JSX.Element> {
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
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">Submissions</h1>
<h1 className="text-2xl font-bold tracking-tight">{t("homework.grade.submissions")}</h1>
<p className="text-muted-foreground truncate">{assignment.title}</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>Exam: {assignment.sourceExamTitle}</span>
<span>{t("homework.grade.exam")}: {assignment.sourceExamTitle}</span>
<span aria-hidden="true"></span>
<span className="tabular-nums">Targets: {assignment.targetCount}</span>
<span className="tabular-nums">{t("homework.grade.targets")}: {assignment.targetCount}</span>
<span aria-hidden="true"></span>
<span className="tabular-nums">Submitted: {assignment.submittedCount}</span>
<span className="tabular-nums">{t("homework.grade.submittedCount")}: {assignment.submittedCount}</span>
<span aria-hidden="true"></span>
<span className="tabular-nums">Graded: {assignment.gradedCount}</span>
<span className="tabular-nums">{t("homework.grade.gradedCount")}: {assignment.gradedCount}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href="/teacher/homework/submissions">Back</Link>
<Link href="/teacher/homework/submissions">{t("homework.grade.back")}</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/teacher/homework/assignments/${id}`}>Open Assignment</Link>
<Link href={`/teacher/homework/assignments/${id}`}>{t("homework.grade.openAssignment")}</Link>
</Button>
</div>
</div>
@@ -54,11 +56,11 @@ export default async function HomeworkAssignmentSubmissionsPage({ params }: { pa
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Status</TableHead>
<TableHead>Submitted</TableHead>
<TableHead>Score</TableHead>
<TableHead>Action</TableHead>
<TableHead>{t("homework.grade.student")}</TableHead>
<TableHead>{t("homework.grade.status")}</TableHead>
<TableHead>{t("homework.grade.submitted")}</TableHead>
<TableHead>{t("homework.grade.score")}</TableHead>
<TableHead>{t("homework.grade.action")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -69,13 +71,13 @@ export default async function HomeworkAssignmentSubmissionsPage({ params }: { pa
<Badge variant="outline" className="capitalize">
{s.status}
</Badge>
{s.isLate ? <span className="ml-2 text-xs text-destructive">Late</span> : null}
{s.isLate ? <span className="ml-2 text-xs text-destructive">{t("homework.grade.late")}</span> : null}
</TableCell>
<TableCell className="text-muted-foreground tabular-nums">{s.submittedAt ? formatDate(s.submittedAt) : "-"}</TableCell>
<TableCell className="tabular-nums">{typeof s.score === "number" ? s.score : "-"}</TableCell>
<TableCell>
<Link href={`/teacher/homework/submissions/${s.id}`} className="text-sm underline-offset-4 hover:underline">
Grade
{t("homework.grade.title")}
</Link>
</TableCell>
</TableRow>

View File

@@ -0,0 +1,15 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex w-full justify-center items-center min-h-[calc(100vh-160px)] p-8 max-w-[1200px] mx-auto">
<div className="w-full space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-72" />
</div>
<Skeleton className="h-[600px] w-full" />
</div>
</div>
)
}

View File

@@ -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<SearchParams> }): Promise<JSX.Element> {
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 (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">
<div>
<h1 className="text-2xl font-bold tracking-tight">Assignments</h1>
<h1 className="text-2xl font-bold tracking-tight">{t("homework.list.title")}</h1>
<p className="text-muted-foreground">
{filteredClassId ? `Filtered by class: ${className ?? filteredClassId}` : "Manage homework assignments."}
{filteredClassId ? t("homework.list.filterByClass", { className: className ?? filteredClassId }) : t("homework.list.description")}
</p>
</div>
<div className="flex items-center gap-2">
{filteredClassId ? (
<Button asChild variant="outline">
<Link href="/teacher/homework/assignments">Clear filter</Link>
<Link href="/teacher/homework/assignments">{t("homework.list.clearFilters")}</Link>
</Button>
) : null}
<Button asChild>
@@ -60,7 +73,7 @@ export default async function AssignmentsPage({ searchParams }: { searchParams:
}
>
<PlusCircle className="mr-2 h-4 w-4" aria-hidden="true" />
Create Assignment
{t("homework.list.create")}
</Link>
</Button>
</div>
@@ -68,11 +81,11 @@ export default async function AssignmentsPage({ searchParams }: { searchParams:
{!hasAssignments ? (
<EmptyState
title="No assignments"
description={filteredClassId ? "No assignments for this class yet." : "You haven't created any assignments yet."}
title={t("homework.list.empty")}
description={filteredClassId ? t("homework.list.emptyFiltered") : t("homework.list.emptyDescription")}
icon={PenTool}
action={{
label: "Create Assignment",
label: t("homework.list.create"),
href:
filteredClassId
? `/teacher/homework/assignments/create?classId=${encodeURIComponent(filteredClassId)}`
@@ -84,15 +97,21 @@ export default async function AssignmentsPage({ searchParams }: { searchParams:
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead>Due</TableHead>
<TableHead>Source Exam</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[260px]">{t("homework.list.columns.title")}</TableHead>
<TableHead>{t("homework.list.columns.status")}</TableHead>
<TableHead>{t("homework.list.columns.dueAt")}</TableHead>
<TableHead className="w-[180px]">{t("homework.list.columns.submissionRate")}</TableHead>
<TableHead className="text-right">{t("homework.list.columns.averageScore")}</TableHead>
<TableHead className="text-right">{t("homework.list.columns.overdue")}</TableHead>
<TableHead>{t("homework.list.columns.sourceExam")}</TableHead>
<TableHead>{t("homework.list.columns.createdAt")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((a) => (
{pagedAssignments.map((a) => {
const submissionRate = a.targetCount > 0 ? (a.submittedCount / a.targetCount) * 100 : 0
const hasOverdue = a.overdueCount > 0
return (
<TableRow key={a.id}>
<TableCell className="font-medium">
<Link
@@ -104,16 +123,47 @@ export default async function AssignmentsPage({ searchParams }: { searchParams:
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{a.status}
{t(`homework.status.${a.status}`)}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground tabular-nums">{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="text-muted-foreground truncate max-w-[200px]">{a.sourceExamTitle}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress value={submissionRate} className="h-2 w-24" aria-label={t("homework.list.columns.submissionRate")} />
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
{a.submittedCount}/{a.targetCount}
</span>
</div>
</TableCell>
<TableCell className="text-right tabular-nums">
{a.averageScore !== null ? formatNumber(a.averageScore, 1) : "-"}
</TableCell>
<TableCell className="text-right">
{hasOverdue ? (
<Badge variant="destructive" className="gap-1">
<AlertCircle className="h-3 w-3" aria-hidden="true" />
{a.overdueCount}
</Badge>
) : (
<span className="text-muted-foreground tabular-nums">0</span>
)}
</TableCell>
<TableCell className="text-muted-foreground truncate max-w-[160px]">{a.sourceExamTitle}</TableCell>
<TableCell className="text-muted-foreground tabular-nums">{formatDate(a.createdAt)}</TableCell>
</TableRow>
))}
)
})}
</TableBody>
</Table>
<ListPagination
page={currentPage}
pageSize={PAGE_SIZE}
total={total}
totalPages={totalPages}
basePath="/teacher/homework/assignments"
searchParams={sp}
itemLabel={t("homework.list.pagination.itemLabel")}
/>
</div>
)}
</div>

View File

@@ -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,
},
};
});

View File

@@ -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,59 +165,59 @@ 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")}
>
<Eye className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0" aria-label="Open menu">
<span className="sr-only">Open menu</span>
<Button variant="ghost" className="h-8 w-8 p-0" aria-label={t("exam.actions.openMenu")}>
<span className="sr-only">{t("exam.actions.openMenu")}</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuLabel>{t("exam.actions.copyId")}</DropdownMenuLabel>
<DropdownMenuItem onClick={copyId}>
<Copy className="mr-2 h-4 w-4" /> Copy ID
<Copy className="mr-2 h-4 w-4" /> {t("exam.actions.copyId")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
<Pencil className="mr-2 h-4 w-4" /> Edit
<Pencil className="mr-2 h-4 w-4" /> {t("exam.actions.edit")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`/teacher/exams/${exam.id}/build`)}>
<MoreHorizontal className="mr-2 h-4 w-4" /> Build
<MoreHorizontal className="mr-2 h-4 w-4" /> {t("exam.actions.build")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={duplicateExam} disabled={isWorking}>
<Copy className="mr-2 h-4 w-4" /> Duplicate
<Copy className="mr-2 h-4 w-4" /> {t("exam.actions.duplicate")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setStatus("published")}
disabled={isWorking || exam.status === "published"}
>
<UploadCloud className="mr-2 h-4 w-4" /> Publish
<UploadCloud className="mr-2 h-4 w-4" /> {t("exam.actions.publish")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setStatus("draft")}
disabled={isWorking || exam.status === "draft"}
>
<Undo2 className="mr-2 h-4 w-4" /> Move to Draft
<Undo2 className="mr-2 h-4 w-4" /> {t("exam.actions.moveToDraft")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setStatus("archived")}
disabled={isWorking || exam.status === "archived"}
>
<Archive className="mr-2 h-4 w-4" /> Archive
<Archive className="mr-2 h-4 w-4" /> {t("exam.actions.archive")}
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDeleteDialog(true)}
disabled={isWorking}
>
<Trash className="mr-2 h-4 w-4" /> Delete
<Trash className="mr-2 h-4 w-4" /> {t("exam.actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -224,14 +226,13 @@ export function ExamActions({ exam }: ExamActionsProps) {
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogTitle>{t("exam.actions.deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the exam
&quot;{exam.title}&quot; and remove all associated data.
{t("exam.actions.deleteConfirmDescription", { title: exam.title })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t("exam.actions.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={(e) => {
@@ -240,7 +241,7 @@ export function ExamActions({ exam }: ExamActionsProps) {
}}
disabled={isWorking}
>
Delete
{t("exam.actions.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -253,7 +254,7 @@ export function ExamActions({ exam }: ExamActionsProps) {
</div>
<ScrollArea className="flex-1">
{loadingPreview ? (
<div className="py-20 text-center text-muted-foreground">Loading preview...</div>
<div className="py-20 text-center text-muted-foreground">{t("exam.actions.loadingPreview")}</div>
) : previewNodes && previewNodes.length > 0 ? (
<div className="max-w-3xl mx-auto py-8 px-6">
<ExamPaperPreview
@@ -267,7 +268,7 @@ export function ExamActions({ exam }: ExamActionsProps) {
</div>
) : (
<div className="py-20 text-center text-muted-foreground">
No questions in this exam.
{t("exam.actions.noQuestions")}
</div>
)}
</ScrollArea>

View File

@@ -7,21 +7,24 @@ import { cn, formatDate } from "@/shared/lib/utils"
import { Exam } from "../types"
import { ExamActions } from "./exam-actions"
export const examColumns: ColumnDef<Exam>[] = [
type TranslationFn = (key: string, params?: Record<string, unknown>) => string
export function createExamColumns(t: TranslationFn): ColumnDef<Exam>[] {
return [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
aria-label={t("exam.actions.selectAll")}
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
aria-label={t("exam.actions.selectRow")}
/>
),
enableSorting: false,
@@ -30,16 +33,16 @@ export const examColumns: ColumnDef<Exam>[] = [
},
{
accessorKey: "title",
header: "Exam Info",
header: t("exam.columns.examInfo"),
cell: ({ row }) => (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-base">{row.original.title}</span>
{row.original.tags && row.original.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{row.original.tags.slice(0, 2).map((t, idx) => (
<Badge key={`${t}-${idx}`} variant="secondary" className="h-5 px-1.5 text-[10px]">
{t}
{row.original.tags.slice(0, 2).map((tag, idx) => (
<Badge key={`${tag}-${idx}`} variant="secondary" className="h-5 px-1.5 text-[10px]">
{tag}
</Badge>
))}
{row.original.tags.length > 2 && (
@@ -58,11 +61,9 @@ export const examColumns: ColumnDef<Exam>[] = [
},
{
accessorKey: "status",
header: "Status",
header: t("exam.columns.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"
@@ -79,20 +80,20 @@ export const examColumns: ColumnDef<Exam>[] = [
status === "draft" && "bg-amber-100 text-amber-800 hover:bg-amber-200 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:border-amber-800"
)}
>
{status}
{t(`exam.status.${status}`)}
</Badge>
)
},
},
{
id: "stats",
header: "Stats",
header: t("exam.columns.stats"),
cell: ({ row }) => (
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">{row.original.questionCount} Qs</span>
<span className="font-medium text-foreground">{row.original.questionCount} {t("exam.columns.questions")}</span>
<span></span>
<span>{row.original.totalScore} Pts</span>
<span>{row.original.totalScore} {t("exam.columns.points")}</span>
</div>
<div className="flex items-center gap-1">
<span>{row.original.durationMin} min</span>
@@ -102,7 +103,7 @@ export const examColumns: ColumnDef<Exam>[] = [
},
{
accessorKey: "difficulty",
header: "Difficulty",
header: t("exam.columns.difficulty"),
cell: ({ row }) => {
const diff = row.original.difficulty
return (
@@ -121,7 +122,7 @@ export const examColumns: ColumnDef<Exam>[] = [
))}
</div>
<span className="text-[10px] text-muted-foreground font-medium">
{diff === 1 ? "Easy" : diff === 2 ? "Easy-Med" : diff === 3 ? "Medium" : diff === 4 ? "Med-Hard" : "Hard"}
{t(`exam.difficulty.${diff}`)}
</span>
</div>
)
@@ -129,7 +130,7 @@ export const examColumns: ColumnDef<Exam>[] = [
},
{
id: "dates",
header: "Date",
header: t("exam.columns.date"),
cell: ({ row }) => {
const scheduled = row.original.scheduledAt
const created = row.original.createdAt
@@ -138,12 +139,12 @@ export const examColumns: ColumnDef<Exam>[] = [
<div className="flex flex-col gap-0.5 text-xs">
{scheduled ? (
<>
<span className="font-medium text-blue-600 dark:text-blue-400">Scheduled</span>
<span className="font-medium text-blue-600 dark:text-blue-400">{t("exam.columns.scheduled")}</span>
<span className="text-muted-foreground">{formatDate(scheduled)}</span>
</>
) : (
<>
<span className="text-muted-foreground">Created</span>
<span className="text-muted-foreground">{t("exam.columns.created")}</span>
<span>{formatDate(created)}</span>
</>
)}
@@ -156,3 +157,4 @@ export const examColumns: ColumnDef<Exam>[] = [
cell: ({ row }) => <ExamActions exam={row.original} />,
},
]
}

View File

@@ -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<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
interface DataTableProps {
data: Exam[]
}
export function ExamDataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
export function ExamDataTable({ data }: DataTableProps) {
const t = useTranslations("examHomework")
const [sorting, setSorting] = React.useState<SortingState>([])
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
const columns = React.useMemo(
() => createExamColumns((key, params) => t(key, params as Record<string, string | number | Date> | undefined)),
[t]
)
const table = useReactTable({
data,
columns,
@@ -81,7 +88,7 @@ export function ExamDataTable<TData, TValue>({ columns, data }: DataTableProps<T
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
{t("common.noResults")}
</TableCell>
</TableRow>
)}
@@ -90,14 +97,13 @@ export function ExamDataTable<TData, TValue>({ columns, data }: DataTableProps<T
</div>
<div className="flex items-center justify-between px-2 py-4">
<div className="flex-1 text-sm text-muted-foreground">
{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")}.
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<p className="text-sm font-medium">Page</p>
<p className="text-sm font-medium">{t("common.page")}</p>
<span className="text-sm font-medium">
{table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
{table.getState().pagination.pageIndex + 1} {t("common.of")} {table.getPageCount()}
</span>
</div>
<div className="flex items-center space-x-2">
@@ -107,7 +113,7 @@ export function ExamDataTable<TData, TValue>({ columns, data }: DataTableProps<T
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">Go to previous page</span>
<span className="sr-only">{t("common.page")}</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
@@ -116,7 +122,7 @@ export function ExamDataTable<TData, TValue>({ columns, data }: DataTableProps<T
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">Go to next page</span>
<span className="sr-only">{t("common.page")}</span>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
@@ -125,4 +131,3 @@ export function ExamDataTable<TData, TValue>({ columns, data }: DataTableProps<T
</div>
)
}

View File

@@ -13,6 +13,7 @@ import {
getActiveClassStudentIdsForHomework,
getClassTeacherById,
getExamWithQuestionsForHomework,
getHomeworkSubmissionForGrading,
getHomeworkSubmissionForPermission,
getTeacherAssignedSubjectIds,
gradeHomeworkAnswers,
@@ -71,14 +72,22 @@ 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<ReturnType<typeof getExamWithQuestionsForHomework>> = 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" }
}
if (!isQuickAssignment && exam) {
const assignedSubjectSet = new Set(assignedSubjectIds)
if (!exam.subjectId) {
return { success: false, message: "Exam subject not set" }
@@ -87,6 +96,7 @@ export async function createHomeworkAssignmentAction(
return { success: false, message: "Not assigned to this subject" }
}
}
}
const classStudentIds = await getActiveClassStudentIdsForHomework(
input.classId,
@@ -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<ActionState<string>> {
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) => ({

View File

@@ -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 (
<Button type="submit" disabled={pending}>
{pending ? "Creating..." : "Create Assignment"}
{pending ? t("homework.form.submitting") : t("homework.form.submit")}
</Button>
)
}
@@ -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<string>(initialExamId)
const initialClassId = useMemo(() => {
@@ -40,43 +46,100 @@ export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]
}, [classes, searchParams])
const [classId, setClassId] = useState<string>(initialClassId)
const [allowLate, setAllowLate] = useState<boolean>(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
}
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 (
<Card>
<Card className="relative">
{isSubmitting && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/60 backdrop-blur-sm">
<div className="flex items-center gap-2 text-sm font-medium">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
{t("homework.form.creating")}
</div>
</div>
)}
<CardHeader>
<CardTitle>Create Assignment</CardTitle>
<CardTitle>{t("homework.form.createTitle")}</CardTitle>
</CardHeader>
<CardContent>
<form action={handleSubmit} className="space-y-6">
{/* 模式切换 */}
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setMode("quick")}
className={cn(
"flex items-center gap-2 rounded-md border p-3 text-left transition-colors",
mode === "quick"
? "border-primary bg-primary/5 text-primary"
: "border-border hover:bg-muted/50"
)}
>
<FileText className="h-4 w-4 shrink-0" aria-hidden="true" />
<div>
<div className="text-sm font-medium">{t("homework.form.quickMode")}</div>
<div className="text-xs text-muted-foreground">{t("homework.form.quickModeDescription")}</div>
</div>
</button>
<button
type="button"
onClick={() => setMode("exam")}
disabled={exams.length === 0}
className={cn(
"flex items-center gap-2 rounded-md border p-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-50",
mode === "exam"
? "border-primary bg-primary/5 text-primary"
: "border-border hover:bg-muted/50"
)}
>
<FileQuestion className="h-4 w-4 shrink-0" aria-hidden="true" />
<div>
<div className="text-sm font-medium">{t("homework.form.examMode")}</div>
<div className="text-xs text-muted-foreground">{t("homework.form.examModeDescription")}</div>
</div>
</button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2 md:col-span-2">
<Label>Class</Label>
<Label>{t("homework.form.class")}</Label>
<Select value={classId} onValueChange={setClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
<SelectValue placeholder={t("homework.form.selectClass")} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
@@ -89,11 +152,12 @@ export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]
<input type="hidden" name="classId" value={classId} />
</div>
{mode === "exam" && (
<div className="grid gap-2 md:col-span-2">
<Label>Source Exam</Label>
<Label>{t("homework.form.sourceExam")}</Label>
<Select value={examId} onValueChange={setExamId}>
<SelectTrigger>
<SelectValue placeholder="Select an exam" />
<SelectValue placeholder={t("homework.form.selectExam")} />
</SelectTrigger>
<SelectContent>
{exams.map((e) => (
@@ -105,24 +169,37 @@ export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]
</Select>
<input type="hidden" name="sourceExamId" value={examId} />
</div>
)}
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="title">Assignment Title (optional)</Label>
<Input id="title" name="title" placeholder="Defaults to exam title" />
<Label htmlFor="title">
{t("homework.form.assignmentTitle")} {mode === "quick" && <span className="text-destructive">*</span>}
</Label>
<Input
id="title"
name="title"
placeholder={mode === "exam" ? t("homework.form.titlePlaceholderExam") : t("homework.form.titlePlaceholderQuick")}
required={mode === "quick"}
/>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="description">Description (optional)</Label>
<Textarea id="description" name="description" className="min-h-[80px]" />
<Label htmlFor="description">{t("homework.form.description")}</Label>
<Textarea
id="description"
name="description"
className="min-h-[80px]"
placeholder={mode === "quick" ? t("homework.form.descriptionPlaceholderQuick") : undefined}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="availableAt">Available At (optional)</Label>
<Label htmlFor="availableAt">{t("homework.form.availableAt")}</Label>
<Input id="availableAt" name="availableAt" type="datetime-local" />
</div>
<div className="grid gap-2">
<Label htmlFor="dueAt">Due At (optional)</Label>
<Label htmlFor="dueAt">{t("homework.form.dueAt")}</Label>
<Input id="dueAt" name="dueAt" type="datetime-local" />
</div>
@@ -133,29 +210,19 @@ export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]
checked={allowLate}
onChange={(e) => setAllowLate(e.target.checked)}
/>
<Label htmlFor="allowLate">Allow late submissions</Label>
<Label htmlFor="allowLate">{t("homework.form.allowLate")}</Label>
<input type="hidden" name="allowLate" value={allowLate ? "true" : "false"} />
</div>
<div className="grid gap-2">
<Label htmlFor="lateDueAt">Late Due At (optional)</Label>
<Label htmlFor="lateDueAt">{t("homework.form.lateDueAt")}</Label>
<Input id="lateDueAt" name="lateDueAt" type="datetime-local" />
</div>
<div className="grid gap-2">
<Label htmlFor="maxAttempts">Max Attempts</Label>
<Label htmlFor="maxAttempts">{t("homework.form.maxAttempts")}</Label>
<Input id="maxAttempts" name="maxAttempts" type="number" min={1} max={20} defaultValue={1} />
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="targetStudentIdsText">Target student IDs (optional)</Label>
<Textarea
id="targetStudentIdsText"
name="targetStudentIdsText"
placeholder="Optional. If provided, targets will be limited to students in the selected class."
className="min-h-[90px]"
/>
</div>
</div>
<CardFooter className="justify-end">

View File

@@ -2,6 +2,7 @@
import { useState } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import {
Check,
@@ -24,6 +25,7 @@ import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Separator } from "@/shared/components/ui/separator"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/components/ui/tooltip"
import { gradeHomeworkSubmissionAction } from "../actions"
import { formatDate } from "@/shared/lib/utils"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
@@ -63,6 +65,7 @@ export function HomeworkGradingView({
submittedAt,
}: HomeworkGradingViewProps) {
const router = useRouter()
const t = useTranslations("examHomework")
const [answers, setAnswers] = useState(() => applyAutoGrades(initialAnswers))
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -129,11 +132,11 @@ export function HomeworkGradingView({
const result = await gradeHomeworkSubmissionAction(null, formData)
if (result.success) {
toast.success("Grading saved successfully")
toast.success(t("homework.grade.gradesSaved"))
// Optionally redirect or stay
router.refresh()
} else {
toast.error(result.message || "Failed to save grading")
toast.error(result.message || t("homework.grade.gradesSaveFailed"))
}
setIsSubmitting(false)
}
@@ -167,11 +170,11 @@ export function HomeworkGradingView({
{ans.questionType.replace("_", " ")}
</span>
{isAutoGradable(ans) && (
<Badge variant="secondary" className="text-[10px] h-5">Auto-graded</Badge>
<Badge variant="secondary" className="text-[10px] h-5">{t("homework.grade.autoGraded")}</Badge>
)}
</div>
<CardTitle className="text-base font-medium leading-relaxed pt-2">
{ans.questionContent?.text || "No question text"}
{ans.questionContent?.text || t("homework.grade.noQuestionText")}
</CardTitle>
</div>
<div className="flex flex-col items-end gap-1">
@@ -188,7 +191,7 @@ export function HomeworkGradingView({
{/* Student Answer Display */}
<div className="space-y-3">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
<User className="h-3 w-3" /> Student Answer
<User className="h-3 w-3" /> {t("homework.grade.studentAnswer")}
</Label>
<div className="rounded-md border bg-background p-4 shadow-sm">
@@ -250,10 +253,10 @@ export function HomeworkGradingView({
{ans.questionType === "text" && (
<div className="space-y-2">
<Label className="text-xs font-semibold text-emerald-600/90 uppercase tracking-wider flex items-center gap-2">
<Check className="h-3 w-3" /> Reference Answer
<Check className="h-3 w-3" /> {t("homework.grade.referenceAnswer")}
</Label>
<div className="rounded-md border border-emerald-200 bg-emerald-50/30 p-4 text-sm text-muted-foreground dark:border-emerald-900/50 dark:bg-emerald-950/10">
{getTextCorrectAnswers(ans.questionContent).join(" / ") || "No reference answer provided."}
{getTextCorrectAnswers(ans.questionContent).join(" / ") || t("homework.grade.noReferenceAnswer")}
</div>
</div>
)}
@@ -270,7 +273,7 @@ export function HomeworkGradingView({
className={getCorrectnessState(ans) === "correct" ? "bg-emerald-600 hover:bg-emerald-700 text-white border-transparent" : "text-muted-foreground hover:text-emerald-600 hover:border-emerald-200"}
onClick={() => handleMarkCorrect(ans.id)}
>
<Check className="mr-1 h-4 w-4" /> Correct
<Check className="mr-1 h-4 w-4" /> {t("homework.grade.correctButton")}
</Button>
<Button
variant={getCorrectnessState(ans) === "incorrect" ? "destructive" : "outline"}
@@ -278,14 +281,14 @@ export function HomeworkGradingView({
className={getCorrectnessState(ans) === "incorrect" ? "" : "text-muted-foreground hover:text-red-600 hover:border-red-200"}
onClick={() => handleMarkIncorrect(ans.id)}
>
<X className="mr-1 h-4 w-4" /> Incorrect
<X className="mr-1 h-4 w-4" /> {t("homework.grade.incorrectButton")}
</Button>
</div>
<Separator orientation="vertical" className="h-6 hidden sm:block" />
<div className="flex items-center gap-2">
<Label htmlFor={`score-${ans.id}`} className="whitespace-nowrap text-sm font-medium">Score:</Label>
<Label htmlFor={`score-${ans.id}`} className="whitespace-nowrap text-sm font-medium">{t("homework.grade.scoreLabel")}:</Label>
<Input
id={`score-${ans.id}`}
type="number"
@@ -307,7 +310,7 @@ export function HomeworkGradingView({
onClick={() => setShowFeedbackByAnswerId(prev => ({ ...prev, [ans.id]: !prev[ans.id] }))}
>
<MessageSquarePlus className="mr-2 h-4 w-4" />
{showFeedbackByAnswerId[ans.id] ? "Hide Feedback" : "Add Feedback"}
{showFeedbackByAnswerId[ans.id] ? t("homework.grade.hideFeedback") : t("homework.grade.addFeedback")}
</Button>
</div>
@@ -315,7 +318,7 @@ export function HomeworkGradingView({
{showFeedbackByAnswerId[ans.id] && (
<div className="w-full animate-in fade-in slide-in-from-top-2 duration-200">
<Textarea
placeholder={`Provide feedback for ${studentName}...`}
placeholder={t("homework.grade.feedbackPlaceholder", { name: studentName })}
value={ans.feedback ?? ""}
onChange={(e) => handleFeedbackChange(ans.id, e.target.value)}
className="min-h-[80px] bg-background"
@@ -333,13 +336,13 @@ export function HomeworkGradingView({
<div className="lg:col-span-3 h-full flex flex-col gap-6">
<Card className="flex flex-col shadow-md border-t-4 border-t-primary">
<CardHeader className="pb-2">
<CardTitle className="text-lg">Grading Summary</CardTitle>
<CardTitle className="text-lg">{t("homework.grade.gradingSummary")}</CardTitle>
<CardDescription>{assignmentTitle}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Total Score</span>
<span className="text-muted-foreground">{t("homework.grade.totalScore")}</span>
<span className="font-bold">{currentTotal} / {maxTotal}</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
@@ -353,16 +356,16 @@ export function HomeworkGradingView({
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2 text-muted-foreground">
<User className="h-4 w-4" /> Student
<User className="h-4 w-4" /> {t("homework.grade.student")}
</span>
<span className="font-medium">{studentName}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-2 text-muted-foreground">
<Clock className="h-4 w-4" /> Submitted
<Clock className="h-4 w-4" /> {t("homework.grade.submitted")}
</span>
<span className="font-medium">
{submittedAt ? new Date(submittedAt).toLocaleDateString() : "N/A"}
{submittedAt ? formatDate(submittedAt) : "N/A"}
</span>
</div>
</div>
@@ -372,15 +375,15 @@ export function HomeworkGradingView({
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col items-center justify-center rounded-md border bg-emerald-50/50 p-2 dark:bg-emerald-950/20">
<span className="text-2xl font-bold text-emerald-600">{correctCount}</span>
<span className="text-xs text-muted-foreground">Correct</span>
<span className="text-xs text-muted-foreground">{t("homework.grade.correct")}</span>
</div>
<div className="flex flex-col items-center justify-center rounded-md border bg-red-50/50 p-2 dark:bg-red-950/20">
<span className="text-2xl font-bold text-red-600">{incorrectCount}</span>
<span className="text-xs text-muted-foreground">Incorrect</span>
<span className="text-xs text-muted-foreground">{t("homework.grade.incorrect")}</span>
</div>
<div className="flex flex-col items-center justify-center rounded-md border bg-amber-50/50 p-2 dark:bg-amber-950/20">
<span className="text-2xl font-bold text-amber-600">{partialCount}</span>
<span className="text-xs text-muted-foreground">Partial</span>
<span className="text-xs text-muted-foreground">{t("homework.grade.partial")}</span>
</div>
</div>
@@ -388,7 +391,7 @@ export function HomeworkGradingView({
<div>
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3 block">
Question Status
{t("homework.grade.questionStatus")}
</Label>
<div className="grid grid-cols-5 gap-2">
{answers.map((ans, i) => {
@@ -424,10 +427,10 @@ export function HomeworkGradingView({
disabled={isSubmitting}
>
{isSubmitting ? (
<>Saving...</>
<>{t("homework.grade.saving")}</>
) : (
<>
<Save className="mr-2 h-4 w-4" /> Submit Grades
<Save className="mr-2 h-4 w-4" /> {t("homework.grade.submitGrades")}
</>
)}
</Button>
@@ -442,10 +445,10 @@ export function HomeworkGradingView({
disabled={!prevSubmissionId}
onClick={() => prevSubmissionId && router.push(`/teacher/homework/submissions/${prevSubmissionId}`)}
>
<ChevronLeft className="mr-1 h-4 w-4" /> Prev
<ChevronLeft className="mr-1 h-4 w-4" /> {t("homework.grade.prev")}
</Button>
</TooltipTrigger>
<TooltipContent>Previous Student</TooltipContent>
<TooltipContent>{t("homework.grade.previousStudent")}</TooltipContent>
</Tooltip>
<Tooltip>
@@ -457,10 +460,10 @@ export function HomeworkGradingView({
disabled={!nextSubmissionId}
onClick={() => nextSubmissionId && router.push(`/teacher/homework/submissions/${nextSubmissionId}`)}
>
Next <ChevronRight className="ml-1 h-4 w-4" />
{t("homework.grade.next")} <ChevronRight className="ml-1 h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Next Student</TooltipContent>
<TooltipContent>{t("homework.grade.nextStudent")}</TooltipContent>
</Tooltip>
</div>
</CardFooter>
@@ -470,7 +473,7 @@ export function HomeworkGradingView({
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<p>
Grades are saved automatically when you click Submit. Students will see their grades and feedback immediately after you submit.
{t("homework.grade.gradesAutoSaveNote")}
</p>
</div>
</div>

View File

@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
@@ -73,6 +74,7 @@ type HomeworkTakeViewProps = {
export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeViewProps) {
const router = useRouter()
const t = useTranslations("examHomework")
const [submissionId, setSubmissionId] = useState<string | null>(initialData.submission?.id ?? null)
const [submissionStatus, setSubmissionStatus] = useState<string>(initialData.submission?.status ?? "not_started")
const [isBusy, setIsBusy] = useState(false)
@@ -128,10 +130,10 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
if (res.success && res.data) {
setSubmissionId(res.data)
setSubmissionStatus("started")
toast.success("Started")
toast.success(t("homework.take.startSuccess"))
router.refresh()
} else {
toast.error(res.message || "Failed to start")
toast.error(res.message || t("homework.take.startFailed"))
}
setIsBusy(false)
}
@@ -145,8 +147,8 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
fd.set("questionId", questionId)
fd.set("answerJson", JSON.stringify({ answer: payload }))
const res = await saveHomeworkAnswerAction(null, fd)
if (res.success) toast.success("Saved")
else toast.error(res.message || "Failed to save")
if (res.success) toast.success(t("homework.take.saved"))
else toast.error(res.message || t("homework.take.saveFailed"))
// setIsBusy(false)
}
@@ -162,7 +164,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
fd.set("answerJson", JSON.stringify({ answer: payload }))
const res = await saveHomeworkAnswerAction(null, fd)
if (!res.success) {
toast.error(res.message || "Failed to save")
toast.error(res.message || t("homework.take.saveFailed"))
setIsBusy(false)
return
}
@@ -172,11 +174,11 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
submitFd.set("submissionId", submissionId)
const submitRes = await submitHomeworkAction(null, submitFd)
if (submitRes.success) {
toast.success("Submitted")
toast.success(t("homework.take.submitSuccess"))
setSubmissionStatus("submitted")
router.push("/student/learning/assignments")
} else {
toast.error(submitRes.message || "Failed to submit")
toast.error(submitRes.message || t("homework.take.submitFailed"))
}
setIsBusy(false)
}
@@ -198,32 +200,32 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
<Button asChild variant="ghost" size="sm" className="mr-1">
<Link href="/student/learning/assignments">
<ChevronLeft className="mr-1 h-4 w-4" />
Back
{t("homework.take.back")}
</Link>
</Button>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
<FileText className="h-4 w-4 text-primary" />
</div>
<div>
<h3 className="font-semibold leading-none">Questions</h3>
<h3 className="font-semibold leading-none">{t("homework.take.questions")}</h3>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant={submissionStatus === "started" ? "default" : "secondary"} className="h-5 px-1.5 text-[10px] capitalize">
{submissionStatus === "not_started" ? "Not Started" : submissionStatus}
{submissionStatus === "not_started" ? t("homework.take.notStarted") : submissionStatus}
</Badge>
<span></span>
<span>{initialData.questions.length} Questions</span>
<span>{initialData.questions.length} {t("homework.take.questions")}</span>
</div>
</div>
</div>
{!canEdit ? (
<Button onClick={handleStart} disabled={isBusy} size="sm">
{isBusy ? "Starting..." : "Start Assignment"}
{isBusy ? t("homework.take.starting") : t("homework.take.startAssignment")}
</Button>
) : (
<Button onClick={() => setShowSubmitConfirm(true)} disabled={isBusy} size="sm">
<CheckCircle2 className="mr-2 h-4 w-4" />
{isBusy ? "Submitting..." : "Submit Assignment"}
{isBusy ? t("homework.take.submitting") : t("homework.take.submitAssignment")}
</Button>
)}
</div>
@@ -235,12 +237,12 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center mb-4">
<Clock className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium">Ready to start?</h3>
<h3 className="text-lg font-medium">{t("homework.take.readyToStart")}</h3>
<p className="text-muted-foreground max-w-sm mt-2 mb-6">
Click the &quot;Start Assignment&quot; button above to begin. Your answers will be saved when you click &quot;Save Answer&quot;.
{t("homework.take.readyDescription")}
</p>
<Button onClick={handleStart} disabled={isBusy}>
Start Now
{t("homework.take.startNow")}
</Button>
</div>
)}
@@ -256,10 +258,10 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<CardTitle className="text-base font-medium">
Question {idx + 1}
{t("homework.take.question", { index: idx + 1 })}
</CardTitle>
<CardDescription className="text-xs">
{q.questionType.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} {q.maxScore} points
{q.questionType.replace("_", " ").replace(/\b\w/g, l => l.toUpperCase())} {q.maxScore} {t("homework.take.points")}
</CardDescription>
</div>
</div>
@@ -269,9 +271,9 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
{q.questionType === "text" ? (
<div className="grid gap-2">
<Label className="sr-only">Your answer</Label>
<Label className="sr-only">{t("homework.take.yourAnswer")}</Label>
<Textarea
placeholder="Type your answer here..."
placeholder={t("homework.take.answerPlaceholder")}
value={typeof value === "string" ? value : ""}
onChange={(e) =>
setAnswersByQuestionId((prev) => ({
@@ -298,11 +300,11 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
>
<div className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
<RadioGroupItem value="true" id={`${q.questionId}-true`} />
<Label htmlFor={`${q.questionId}-true`} className="flex-1 cursor-pointer font-normal">True</Label>
<Label htmlFor={`${q.questionId}-true`} className="flex-1 cursor-pointer font-normal">{t("homework.take.true")}</Label>
</div>
<div className="flex items-center space-x-2 rounded-md border p-3 hover:bg-muted/50 transition-colors">
<RadioGroupItem value="false" id={`${q.questionId}-false`} />
<Label htmlFor={`${q.questionId}-false`} className="flex-1 cursor-pointer font-normal">False</Label>
<Label htmlFor={`${q.questionId}-false`} className="flex-1 cursor-pointer font-normal">{t("homework.take.false")}</Label>
</div>
</RadioGroup>
</div>
@@ -362,20 +364,20 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
</div>
</div>
) : (
<div className="text-sm text-muted-foreground italic">Unsupported question type</div>
<div className="text-sm text-muted-foreground italic">{t("homework.take.unsupportedType")}</div>
)}
{submissionStatus === "graded" && (
<div className="mt-6 rounded-md bg-muted/40 p-4 border border-border/50">
{q.feedback ? (
<div className="text-sm space-y-1">
<div className="font-medium text-foreground">Teacher Feedback</div>
<div className="font-medium text-foreground">{t("homework.take.teacherFeedback")}</div>
<div className="text-muted-foreground bg-background p-2 rounded border border-border/50">
{q.feedback}
</div>
</div>
) : (
<div className="text-sm text-muted-foreground italic">No specific feedback provided.</div>
<div className="text-sm text-muted-foreground italic">{t("homework.take.noFeedback")}</div>
)}
</div>
)}
@@ -390,7 +392,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
className="text-muted-foreground hover:text-foreground"
>
<Save className="mr-2 h-3 w-3" />
Save Answer
{t("homework.take.saveAnswer")}
</Button>
</div>
) : null}
@@ -404,22 +406,22 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
<div className="lg:col-span-3 flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4 bg-muted/30">
<h3 className="font-semibold">Assignment Info</h3>
<h3 className="font-semibold">{t("homework.take.assignmentInfo")}</h3>
</div>
<div className="flex-1 p-4 overflow-y-auto">
<div className="space-y-6">
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Status</Label>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.status")}</Label>
<div className="mt-1 flex items-center gap-2">
<Badge variant={submissionStatus === "started" ? "default" : "outline"} className="capitalize">
{submissionStatus === "not_started" ? "not started" : submissionStatus}
{submissionStatus === "not_started" ? t("homework.take.notStarted") : submissionStatus}
</Badge>
</div>
</div>
{dueAt && (
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Due Date</Label>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.dueDate")}</Label>
<div className={cn(
"mt-1 flex items-center gap-2 text-sm font-medium",
isOverdue ? "text-destructive" : isUrgent ? "text-orange-500" : "text-foreground"
@@ -428,11 +430,13 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
<span>{formatDate(dueAt)}</span>
</div>
{isOverdue && (
<p className="mt-1 text-xs text-destructive">Overdue</p>
<p className="mt-1 text-xs text-destructive">{t("homework.take.overdue")}</p>
)}
{isUrgent && !isOverdue && hoursUntilDue !== null && (
<p className="mt-1 text-xs text-orange-500">
{hoursUntilDue === 0 ? "Less than 1 hour left" : `${hoursUntilDue} hour${hoursUntilDue === 1 ? "" : "s"} left`}
{hoursUntilDue === 0
? t("homework.take.lessThanOneHour")
: t("homework.take.hoursLeft", { hours: hoursUntilDue })}
</p>
)}
</div>
@@ -440,27 +444,27 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
{maxAttempts > 0 && (
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Attempts</Label>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.attempts")}</Label>
<div className="mt-1 text-sm">
<span className="font-medium">{attemptsUsed}</span>
<span className="text-muted-foreground"> / {maxAttempts} used</span>
<span className="text-muted-foreground"> {t("homework.take.attemptsUsed", { used: attemptsUsed, max: maxAttempts })}</span>
{attemptsRemaining > 0 && (
<span className="text-muted-foreground"> · {attemptsRemaining} remaining</span>
<span className="text-muted-foreground"> {t("homework.take.attemptsRemaining", { remaining: attemptsRemaining })}</span>
)}
</div>
</div>
)}
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Description</Label>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.description")}</Label>
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
{initialData.assignment.description || "No description provided."}
{initialData.assignment.description || t("homework.take.noDescription")}
</p>
</div>
{showQuestions && (
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Progress</Label>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.progress")}</Label>
<div className="mt-2 grid grid-cols-5 gap-2">
{initialData.questions.map((q, i) => {
const hasAnswer = answersByQuestionId[q.questionId]?.answer !== undefined &&
@@ -479,7 +483,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
"h-8 w-8 rounded flex items-center justify-center text-xs font-medium border transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input"
)}
aria-label={`Jump to question ${i + 1}`}
aria-label={t("homework.take.jumpToQuestion", { index: i + 1 })}
>
{i + 1}
</button>
@@ -494,10 +498,10 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
{canEdit && (
<div className="border-t p-4 bg-muted/20">
<Button className="w-full" onClick={() => setShowSubmitConfirm(true)} disabled={isBusy}>
{isBusy ? "Submitting..." : "Submit All"}
{isBusy ? t("homework.take.submitting") : t("homework.take.submitAll")}
</Button>
<p className="mt-2 text-xs text-center text-muted-foreground">
Make sure you have answered all questions.
{t("homework.take.makeSureAnswered")}
</p>
</div>
)}
@@ -507,15 +511,15 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
<AlertDialog open={showSubmitConfirm} onOpenChange={setShowSubmitConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Submission</AlertDialogTitle>
<AlertDialogTitle>{t("homework.take.confirmSubmit")}</AlertDialogTitle>
<AlertDialogDescription>
{unansweredCount > 0
? `You have ${unansweredCount} unanswered question${unansweredCount === 1 ? "" : "s"}. Submitted answers cannot be changed. Are you sure you want to submit?`
: "All questions have been answered. Submitted answers cannot be changed. Are you sure you want to submit?"}
? t("homework.take.unansweredWarning", { count: unansweredCount })
: t("homework.take.confirmSubmitDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isBusy}>Cancel</AlertDialogCancel>
<AlertDialogCancel disabled={isBusy}>{t("homework.take.cancel")}</AlertDialogCancel>
<AlertDialogAction
disabled={isBusy}
onClick={(e) => {
@@ -524,7 +528,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
void handleSubmit()
}}
>
{isBusy ? "Submitting..." : "Confirm Submit"}
{isBusy ? t("homework.take.submitting") : t("homework.take.confirmSubmitAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -45,7 +45,7 @@ export type HomeworkSubmissionPermissionData = {
export type CreateHomeworkAssignmentData = {
assignmentId: string
sourceExamId: string
sourceExamId: string | null
title: string
description: string | null
structure: unknown
@@ -116,6 +116,32 @@ export const getHomeworkSubmissionForPermission = async (
}
}
/**
* 批改权限校验:获取提交记录及其作业的创建者信息
* 用于 gradeHomeworkSubmissionAction 校验教师是否有权批改该提交
* 返回 null 表示提交记录不存在
*/
export const getHomeworkSubmissionForGrading = async (
submissionId: string
): Promise<{
id: string
assignmentId: string
creatorId: string
sourceExamId: string | null
} | null> => {
const submission = await db.query.homeworkSubmissions.findFirst({
where: eq(homeworkSubmissions.id, submissionId),
with: { assignment: true },
})
if (!submission) return null
return {
id: submission.id,
assignmentId: submission.assignmentId,
creatorId: submission.assignment.creatorId,
sourceExamId: submission.assignment.sourceExamId,
}
}
// ---- Write functions ----
export const createHomeworkAssignment = async (

View File

@@ -0,0 +1,352 @@
{
"exam": {
"list": {
"title": "Exams",
"create": "Create Exam",
"empty": "No exams yet",
"emptyFiltered": "No exams match your filters",
"emptyDescription": "Create your first exam to start assigning and grading.",
"emptyFilteredDescription": "Try clearing filters or adjusting keywords.",
"clearFilters": "Clear filters",
"showing": "Showing",
"examsUnit": "exams",
"searchPlaceholder": "Search exams..."
},
"form": {
"createTitle": "Create Exam",
"createDescription": "Configure a new exam for your classes.",
"buildTitle": "Build Exam",
"buildDescription": "Assemble questions for your exam.",
"title": "Exam Title",
"subject": "Subject",
"grade": "Grade",
"difficulty": "Difficulty",
"totalScore": "Total Score",
"durationMin": "Duration (minutes)",
"scheduledAt": "Scheduled At",
"questions": "Questions",
"missingSubjectOrGrade": "Missing subject or grade configuration",
"previewBeforeCreate": "Please preview and confirm before creating",
"createSuccess": "Exam draft created",
"redirecting": "Redirecting to exam builder...",
"createFailed": "Failed to create exam",
"loadFormFailed": "Failed to load form data",
"loadSubjectsFailed": "Failed to load subjects",
"loadGradesFailed": "Failed to load grades"
},
"status": {
"draft": "Draft",
"published": "Published",
"archived": "Archived"
},
"difficulty": {
"1": "Easy",
"2": "Easy-Med",
"3": "Medium",
"4": "Med-Hard",
"5": "Hard"
},
"actions": {
"preview": "Preview Exam",
"copyId": "Copy ID",
"edit": "Edit",
"build": "Build",
"duplicate": "Duplicate",
"publish": "Publish",
"moveToDraft": "Move to Draft",
"archive": "Archive",
"delete": "Delete",
"deleteConfirmTitle": "Are you absolutely sure?",
"deleteConfirmDescription": "This action cannot be undone. This will permanently delete the exam \"{{title}}\" and remove all associated data.",
"cancel": "Cancel",
"deleteSuccess": "Exam deleted successfully",
"deleteFailed": "Failed to delete exam",
"publishSuccess": "Exam published",
"archiveSuccess": "Exam archived",
"draftSuccess": "Exam moved to draft",
"duplicateSuccess": "Exam duplicated",
"duplicateFailed": "Failed to duplicate exam",
"updateFailed": "Failed to update exam",
"previewFailed": "Failed to load exam preview",
"idCopied": "Exam ID copied to clipboard",
"openMenu": "Open menu",
"selectRow": "Select row",
"selectAll": "Select all",
"noQuestions": "No questions in this exam.",
"loadingPreview": "Loading preview..."
},
"columns": {
"examInfo": "Exam Info",
"status": "Status",
"stats": "Stats",
"difficulty": "Difficulty",
"date": "Date",
"scheduled": "Scheduled",
"created": "Created",
"questions": "Qs",
"points": "Pts"
},
"filters": {
"status": "Status",
"anyStatus": "Any Status",
"difficulty": "Difficulty",
"anyDifficulty": "Any Difficulty"
},
"error": {
"notFound": "Exam not found",
"loadFailed": "Failed to load exam"
}
},
"homework": {
"list": {
"title": "Assignments",
"description": "Manage assignments, view submission rates and grading progress.",
"create": "Create Assignment",
"empty": "No assignments yet",
"emptyFiltered": "No assignments in this class.",
"emptyDescription": "You haven't created any assignments yet.",
"clearFilters": "Clear filters",
"filterByClass": "Filter by class: {{className}}",
"columns": {
"title": "Title",
"status": "Status",
"dueAt": "Due Date",
"submissionRate": "Submission Rate",
"averageScore": "Average Score",
"overdue": "Overdue",
"sourceExam": "Source Exam",
"createdAt": "Created At"
},
"pagination": {
"itemLabel": "assignments"
}
},
"form": {
"createTitle": "Create Assignment",
"quickMode": "Quick Assignment",
"quickModeDescription": "Enter title and description directly, no questions needed",
"examMode": "Exam-based Assignment",
"examModeDescription": "Derive assignment from an existing exam",
"class": "Class",
"selectClass": "Select a class",
"sourceExam": "Source Exam",
"selectExam": "Select an exam",
"assignmentTitle": "Assignment Title",
"titlePlaceholderQuick": "e.g. Recite Lesson 3",
"titlePlaceholderExam": "Defaults to exam title",
"description": "Description (optional)",
"descriptionPlaceholderQuick": "Enter assignment requirements, question content, or instructions...",
"availableAt": "Available At (optional)",
"dueAt": "Due At (optional)",
"allowLate": "Allow late submissions",
"lateDueAt": "Late Due At (optional)",
"maxAttempts": "Max Attempts",
"submit": "Create Assignment",
"submitting": "Creating...",
"creating": "Creating assignment...",
"selectExamRequired": "Please select an exam",
"titleRequired": "Please enter a title",
"selectClassRequired": "Please select a class",
"createSuccess": "Assignment created",
"createFailed": "Failed to create"
},
"take": {
"questions": "Questions",
"question": "Question {{index}}",
"points": "points",
"startAssignment": "Start Assignment",
"submitAssignment": "Submit Assignment",
"submitAll": "Submit All",
"saveAnswer": "Save Answer",
"saved": "Saved",
"saveFailed": "Failed to save",
"starting": "Starting...",
"submitting": "Submitting...",
"started": "Started",
"submitted": "Submitted",
"notStarted": "Not Started",
"readyToStart": "Ready to start?",
"readyDescription": "Click the \"Start Assignment\" button above to begin. Your answers will be saved when you click \"Save Answer\".",
"startNow": "Start Now",
"back": "Back",
"confirmSubmit": "Confirm Submission",
"confirmSubmitDescription": "All questions have been answered. Submitted answers cannot be changed. Are you sure you want to submit?",
"unansweredWarning": "You have {{count}} unanswered question(s). Submitted answers cannot be changed. Are you sure you want to submit?",
"cancel": "Cancel",
"confirmSubmitAction": "Confirm Submit",
"submitSuccess": "Submitted",
"submitFailed": "Failed to submit",
"startSuccess": "Started",
"startFailed": "Failed to start",
"assignmentInfo": "Assignment Info",
"status": "Status",
"dueDate": "Due Date",
"overdue": "Overdue",
"hoursLeft": "{{hours}} hour(s) left",
"lessThanOneHour": "Less than 1 hour left",
"attempts": "Attempts",
"attemptsUsed": "{{used}} / {{max}} used",
"attemptsRemaining": "· {{remaining}} remaining",
"description": "Description",
"noDescription": "No description provided.",
"progress": "Progress",
"jumpToQuestion": "Jump to question {{index}}",
"yourAnswer": "Your answer",
"answerPlaceholder": "Type your answer here...",
"true": "True",
"false": "False",
"unsupportedType": "Unsupported question type",
"teacherFeedback": "Teacher Feedback",
"noFeedback": "No specific feedback provided.",
"makeSureAnswered": "Make sure you have answered all questions."
},
"grade": {
"title": "Grade",
"submissions": "Submissions",
"student": "Student",
"status": "Status",
"submitted": "Submitted",
"score": "Score",
"action": "Action",
"back": "Back",
"openAssignment": "Open Assignment",
"late": "Late",
"targets": "Targets",
"submittedCount": "Submitted",
"gradedCount": "Graded",
"exam": "Exam",
"gradingSummary": "Grading Summary",
"totalScore": "Total Score",
"correct": "Correct",
"incorrect": "Incorrect",
"partial": "Partial",
"questionStatus": "Question Status",
"studentAnswer": "Student Answer",
"referenceAnswer": "Reference Answer",
"noReferenceAnswer": "No reference answer provided.",
"noQuestionText": "No question text",
"autoGraded": "Auto-graded",
"correctButton": "Correct",
"incorrectButton": "Incorrect",
"scoreLabel": "Score",
"addFeedback": "Add Feedback",
"hideFeedback": "Hide Feedback",
"feedbackPlaceholder": "Provide feedback for {{name}}...",
"submitGrades": "Submit Grades",
"saving": "Saving...",
"gradesSaved": "Grading saved successfully",
"gradesSaveFailed": "Failed to save grading",
"previousStudent": "Previous Student",
"nextStudent": "Next Student",
"prev": "Prev",
"next": "Next",
"gradesAutoSaveNote": "Grades are saved automatically when you click Submit. Students will see their grades and feedback immediately after you submit."
},
"review": {
"title": "Review",
"yourAnswer": "Your Answer",
"correctAnswer": "Correct Answer",
"teacherFeedback": "Teacher Feedback",
"score": "Score",
"maxScore": "Max Score"
},
"status": {
"draft": "Draft",
"published": "Published",
"archived": "Archived",
"started": "Started",
"submitted": "Submitted",
"graded": "Graded",
"not_started": "Not Started",
"in_progress": "In Progress"
},
"error": {
"notFound": "Assignment not found",
"submissionNotFound": "Submission not found",
"unauthorized": "Unauthorized",
"submissionLocked": "Submission is locked",
"pastDue": "Past due",
"pastLateDue": "Past late due",
"noActiveStudents": "No active students in this class",
"classNotFound": "Class not found",
"examNotFound": "Exam not found",
"examSubjectNotSet": "Exam subject not set",
"notAssignedToClass": "Not assigned to this class",
"notAssignedToSubject": "Not assigned to this subject",
"notAssigned": "Not assigned",
"notAvailableYet": "Not available yet",
"noAttemptsLeft": "No attempts left",
"assignmentNotFound": "Assignment not found",
"assignmentNotAvailable": "Assignment not available"
}
},
"proctoring": {
"mode": {
"title": "Exam Mode",
"description": "Select exam mode and configure options. Proctored mode enables anti-cheat monitoring.",
"homework": "Homework Mode",
"timed": "Timed Mode",
"proctored": "Proctored Mode",
"homeworkDescription": "Students can answer at any time, no time limit",
"timedDescription": "Timed answering, auto-submit on timeout",
"proctoredDescription": "Timed + anti-cheat + forced fullscreen"
},
"config": {
"duration": "Duration (minutes)",
"durationTimedDescription": "Auto-submit after timeout when student starts",
"durationProctoredDescription": "Required in proctored mode",
"shuffleQuestions": "Shuffle Questions",
"shuffleQuestionsDescription": "Each student sees questions in random order",
"antiCheat": "Enable Anti-cheat Monitoring",
"antiCheatDescription": "Monitor tab switch, copy, right-click, devtools, etc.",
"allowLateStart": "Allow Late Start",
"allowLateStartDescription": "Allow students to enter within a grace period after exam starts",
"lateStartGrace": "Late Start Grace (minutes)",
"lateStartGraceDescription": "No new students allowed after this time"
},
"dashboard": {
"title": "Proctoring Dashboard",
"summary": "Summary",
"students": "Students",
"recentEvents": "Recent Events",
"noEvents": "No events",
"eventCount": "Event Count",
"abnormalCount": "Abnormal Count"
},
"events": {
"tab_switch": "Tab Switch",
"window_blur": "Window Blur",
"copy_attempt": "Copy Attempt",
"paste_attempt": "Paste Attempt",
"right_click": "Right Click",
"devtools_open": "DevTools Open",
"fullscreen_exit": "Fullscreen Exit",
"idle_timeout": "Idle Timeout"
}
},
"common": {
"loading": "Loading...",
"save": "Save",
"cancel": "Cancel",
"confirm": "Confirm",
"back": "Back",
"edit": "Edit",
"delete": "Delete",
"create": "Create",
"search": "Search",
"filter": "Filter",
"noResults": "No results",
"error": "Error",
"success": "Success",
"failed": "Failed",
"retry": "Retry",
"page": "Page",
"of": "of",
"selected": "selected",
"rows": "row(s)",
"view": "View",
"continue": "Continue",
"other": "Other",
"completed": "Completed"
}
}

View File

@@ -0,0 +1,352 @@
{
"exam": {
"list": {
"title": "考试列表",
"create": "创建考试",
"empty": "暂无考试",
"emptyFiltered": "没有匹配筛选条件的考试",
"emptyDescription": "创建第一份考试,开始布置与批改。",
"emptyFilteredDescription": "尝试清除筛选条件或调整关键词。",
"clearFilters": "清除筛选",
"showing": "显示",
"examsUnit": "份考试",
"searchPlaceholder": "搜索考试..."
},
"form": {
"createTitle": "创建考试",
"createDescription": "为班级配置一份新考试。",
"buildTitle": "组卷",
"buildDescription": "为考试组装题目。",
"title": "考试标题",
"subject": "科目",
"grade": "年级",
"difficulty": "难度",
"totalScore": "总分",
"durationMin": "考试时长(分钟)",
"scheduledAt": "计划时间",
"questions": "题目",
"missingSubjectOrGrade": "缺少科目或年级配置",
"previewBeforeCreate": "请先预览并确认后再创建",
"createSuccess": "考试草稿已创建",
"redirecting": "正在跳转到组卷页...",
"createFailed": "创建考试失败",
"loadFormFailed": "加载表单数据失败",
"loadSubjectsFailed": "加载科目失败",
"loadGradesFailed": "加载年级失败"
},
"status": {
"draft": "草稿",
"published": "已发布",
"archived": "已归档"
},
"difficulty": {
"1": "简单",
"2": "偏易",
"3": "中等",
"4": "偏难",
"5": "困难"
},
"actions": {
"preview": "预览考试",
"copyId": "复制 ID",
"edit": "编辑",
"build": "组卷",
"duplicate": "复制",
"publish": "发布",
"moveToDraft": "移至草稿",
"archive": "归档",
"delete": "删除",
"deleteConfirmTitle": "确定要删除吗?",
"deleteConfirmDescription": "此操作不可撤销。将永久删除考试\"{{title}}\"及所有关联数据。",
"cancel": "取消",
"deleteSuccess": "考试已删除",
"deleteFailed": "删除考试失败",
"publishSuccess": "考试已发布",
"archiveSuccess": "考试已归档",
"draftSuccess": "考试已移至草稿",
"duplicateSuccess": "考试已复制",
"duplicateFailed": "复制考试失败",
"updateFailed": "更新考试失败",
"previewFailed": "加载考试预览失败",
"idCopied": "考试 ID 已复制",
"openMenu": "打开菜单",
"selectRow": "选择行",
"selectAll": "全选",
"noQuestions": "此考试暂无题目。",
"loadingPreview": "正在加载预览..."
},
"columns": {
"examInfo": "考试信息",
"status": "状态",
"stats": "统计",
"difficulty": "难度",
"date": "日期",
"scheduled": "已计划",
"created": "创建于",
"questions": "题",
"points": "分"
},
"filters": {
"status": "状态",
"anyStatus": "任意状态",
"difficulty": "难度",
"anyDifficulty": "任意难度"
},
"error": {
"notFound": "考试不存在",
"loadFailed": "加载考试失败"
}
},
"homework": {
"list": {
"title": "作业列表",
"description": "管理作业,查看提交率与批改进度。",
"create": "创建作业",
"empty": "暂无作业",
"emptyFiltered": "该班级还没有作业。",
"emptyDescription": "您还没有创建任何作业。",
"clearFilters": "清除筛选",
"filterByClass": "按班级筛选:{{className}}",
"columns": {
"title": "标题",
"status": "状态",
"dueAt": "截止时间",
"submissionRate": "提交率",
"averageScore": "平均分",
"overdue": "逾期",
"sourceExam": "来源考试",
"createdAt": "创建时间"
},
"pagination": {
"itemLabel": "个作业"
}
},
"form": {
"createTitle": "创建作业",
"quickMode": "快速作业",
"quickModeDescription": "直接输入标题和描述,无需建题",
"examMode": "考试派生作业",
"examModeDescription": "从已有考试派生作业",
"class": "班级",
"selectClass": "选择班级",
"sourceExam": "来源考试",
"selectExam": "选择考试",
"assignmentTitle": "作业标题",
"titlePlaceholderQuick": "例如:背诵第三课课文",
"titlePlaceholderExam": "默认使用考试标题",
"description": "描述(可选)",
"descriptionPlaceholderQuick": "输入作业要求、题目内容或说明...",
"availableAt": "开放时间(可选)",
"dueAt": "截止时间(可选)",
"allowLate": "允许迟交",
"lateDueAt": "迟交截止时间(可选)",
"maxAttempts": "最大尝试次数",
"submit": "创建作业",
"submitting": "创建中...",
"creating": "正在创建作业...",
"selectExamRequired": "请选择考试",
"titleRequired": "请输入标题",
"selectClassRequired": "请选择班级",
"createSuccess": "作业已创建",
"createFailed": "创建失败"
},
"take": {
"questions": "题目",
"question": "第 {{index}} 题",
"points": "分",
"startAssignment": "开始作答",
"submitAssignment": "提交作业",
"submitAll": "全部提交",
"saveAnswer": "保存答案",
"saved": "已保存",
"saveFailed": "保存失败",
"starting": "正在开始...",
"submitting": "正在提交...",
"started": "已开始",
"submitted": "已提交",
"notStarted": "未开始",
"readyToStart": "准备好开始了吗?",
"readyDescription": "点击上方\"开始作答\"按钮。点击\"保存答案\"将保存您的答案。",
"startNow": "立即开始",
"back": "返回",
"confirmSubmit": "确认提交",
"confirmSubmitDescription": "所有题目已作答。提交后答案不可修改,确定要提交吗?",
"unansweredWarning": "您有 {{count}} 道题未作答。提交后答案不可修改,确定要提交吗?",
"cancel": "取消",
"confirmSubmitAction": "确认提交",
"submitSuccess": "已提交",
"submitFailed": "提交失败",
"startSuccess": "已开始",
"startFailed": "开始失败",
"assignmentInfo": "作业信息",
"status": "状态",
"dueDate": "截止时间",
"overdue": "已逾期",
"hoursLeft": "还剩 {{hours}} 小时",
"lessThanOneHour": "不足 1 小时",
"attempts": "尝试次数",
"attemptsUsed": "已用 {{used}} / {{max}}",
"attemptsRemaining": "· 剩余 {{remaining}} 次",
"description": "描述",
"noDescription": "无描述。",
"progress": "进度",
"jumpToQuestion": "跳转到第 {{index}} 题",
"yourAnswer": "你的答案",
"answerPlaceholder": "在此输入答案...",
"true": "正确",
"false": "错误",
"unsupportedType": "不支持的题型",
"teacherFeedback": "教师反馈",
"noFeedback": "无具体反馈。",
"makeSureAnswered": "请确保已作答所有题目。"
},
"grade": {
"title": "批改",
"submissions": "提交记录",
"student": "学生",
"status": "状态",
"submitted": "提交时间",
"score": "分数",
"action": "操作",
"back": "返回",
"openAssignment": "打开作业",
"late": "迟交",
"targets": "应交",
"submittedCount": "已交",
"gradedCount": "已改",
"exam": "考试",
"gradingSummary": "批改摘要",
"totalScore": "总分",
"correct": "正确",
"incorrect": "错误",
"partial": "部分正确",
"questionStatus": "题目状态",
"studentAnswer": "学生答案",
"referenceAnswer": "参考答案",
"noReferenceAnswer": "无参考答案。",
"noQuestionText": "无题目文本",
"autoGraded": "自动判分",
"correctButton": "正确",
"incorrectButton": "错误",
"scoreLabel": "分数",
"addFeedback": "添加反馈",
"hideFeedback": "隐藏反馈",
"feedbackPlaceholder": "为 {{name}} 添加反馈...",
"submitGrades": "提交成绩",
"saving": "保存中...",
"gradesSaved": "批改已保存",
"gradesSaveFailed": "保存批改失败",
"previousStudent": "上一名学生",
"nextStudent": "下一名学生",
"prev": "上一页",
"next": "下一页",
"gradesAutoSaveNote": "点击提交后成绩将自动保存。学生将在您提交后立即看到成绩和反馈。"
},
"review": {
"title": "复习",
"yourAnswer": "你的答案",
"correctAnswer": "正确答案",
"teacherFeedback": "教师反馈",
"score": "得分",
"maxScore": "满分"
},
"status": {
"draft": "草稿",
"published": "已发布",
"archived": "已归档",
"started": "进行中",
"submitted": "已提交",
"graded": "已批改",
"not_started": "未开始",
"in_progress": "进行中"
},
"error": {
"notFound": "作业不存在",
"submissionNotFound": "提交记录不存在",
"unauthorized": "无权限",
"submissionLocked": "提交已锁定",
"pastDue": "已过截止时间",
"pastLateDue": "已过迟交截止时间",
"noActiveStudents": "该班级没有活跃学生",
"classNotFound": "班级不存在",
"examNotFound": "考试不存在",
"examSubjectNotSet": "考试未设置科目",
"notAssignedToClass": "未分配到该班级",
"notAssignedToSubject": "未分配到该科目",
"notAssigned": "未分配该作业",
"notAvailableYet": "作业尚未开放",
"noAttemptsLeft": "尝试次数已用完",
"assignmentNotFound": "作业不存在",
"assignmentNotAvailable": "作业不可用"
}
},
"proctoring": {
"mode": {
"title": "考试模式",
"description": "选择考试模式并配置相关选项。监考模式会启用防作弊监控。",
"homework": "作业模式",
"timed": "限时模式",
"proctored": "监考模式",
"homeworkDescription": "学生可在任意时间作答,无时间限制",
"timedDescription": "限时作答,到时自动提交",
"proctoredDescription": "限时作答 + 防作弊监控 + 强制全屏"
},
"config": {
"duration": "考试时长(分钟)",
"durationTimedDescription": "学生开始作答后,到时自动提交",
"durationProctoredDescription": "监考模式下必须设置考试时长",
"shuffleQuestions": "题目乱序",
"shuffleQuestionsDescription": "每位学生看到的题目顺序随机",
"antiCheat": "启用防作弊监控",
"antiCheatDescription": "监听切屏、复制、右键、开发者工具等异常行为",
"allowLateStart": "允许迟开始",
"allowLateStartDescription": "允许学生在考试开始后一段时间内进入",
"lateStartGrace": "迟到宽限时间(分钟)",
"lateStartGraceDescription": "超过此时间后不允许新学生进入"
},
"dashboard": {
"title": "监考面板",
"summary": "摘要",
"students": "学生",
"recentEvents": "最近事件",
"noEvents": "暂无事件",
"eventCount": "事件数",
"abnormalCount": "异常数"
},
"events": {
"tab_switch": "切换标签页",
"window_blur": "窗口失焦",
"copy_attempt": "复制尝试",
"paste_attempt": "粘贴尝试",
"right_click": "右键点击",
"devtools_open": "开发者工具",
"fullscreen_exit": "退出全屏",
"idle_timeout": "空闲超时"
}
},
"common": {
"loading": "加载中...",
"save": "保存",
"cancel": "取消",
"confirm": "确认",
"back": "返回",
"edit": "编辑",
"delete": "删除",
"create": "创建",
"search": "搜索",
"filter": "筛选",
"noResults": "无结果",
"error": "错误",
"success": "成功",
"failed": "失败",
"retry": "重试",
"page": "页",
"of": "共",
"selected": "已选",
"rows": "行",
"view": "查看",
"continue": "继续",
"other": "其他",
"completed": "已完成"
}
}