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:
396
docs/architecture/audit/exam-homework-audit-report.md
Normal file
396
docs/architecture/audit/exam-homework-audit-report.md
Normal 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)` 接口,关键操作(创建/提交/批改)埋点。
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
</span>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
24
src/app/(dashboard)/teacher/exams/[id]/build/error.tsx
Normal file
24
src/app/(dashboard)/teacher/exams/[id]/build/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function 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>
|
||||
)
|
||||
}
|
||||
16
src/app/(dashboard)/teacher/exams/[id]/build/loading.tsx
Normal file
16
src/app/(dashboard)/teacher/exams/[id]/build/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
src/app/(dashboard)/teacher/exams/[id]/proctoring/error.tsx
Normal file
24
src/app/(dashboard)/teacher/exams/[id]/proctoring/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,36 +97,73 @@ 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) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
href={`/teacher/homework/assignments/${a.id}`}
|
||||
className="hover:underline line-clamp-2 max-w-[240px]"
|
||||
>
|
||||
{a.title}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{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 className="text-muted-foreground tabular-nums">{formatDate(a.createdAt)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{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
|
||||
href={`/teacher/homework/assignments/${a.id}`}
|
||||
className="hover:underline line-clamp-2 max-w-[240px]"
|
||||
>
|
||||
{a.title}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{t(`homework.status.${a.status}`)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground tabular-nums">{a.dueAt ? formatDate(a.dueAt) : "-"}</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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { MoreHorizontal, Eye, Pencil, Trash, Archive, UploadCloud, Undo2, Copy } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
@@ -43,6 +44,7 @@ interface ExamActionsProps {
|
||||
|
||||
export function ExamActions({ exam }: ExamActionsProps) {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("examHomework")
|
||||
const [showViewDialog, setShowViewDialog] = useState(false)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
@@ -76,11 +78,11 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
const nodes = Array.isArray(structure) ? hydrate(structure) : []
|
||||
setPreviewNodes(nodes)
|
||||
} else {
|
||||
toast.error("Failed to load exam preview")
|
||||
toast.error(t("exam.actions.previewFailed"))
|
||||
setShowViewDialog(false)
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to load exam preview")
|
||||
toast.error(t("exam.actions.previewFailed"))
|
||||
setShowViewDialog(false)
|
||||
} finally {
|
||||
setLoadingPreview(false)
|
||||
@@ -89,7 +91,7 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
|
||||
const copyId = () => {
|
||||
navigator.clipboard.writeText(exam.id)
|
||||
toast.success("Exam ID copied to clipboard")
|
||||
toast.success(t("exam.actions.idCopied"))
|
||||
}
|
||||
|
||||
const setStatus = async (status: Exam["status"]) => {
|
||||
@@ -100,13 +102,13 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
formData.set("status", status)
|
||||
const result = await updateExamAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success(status === "published" ? "Exam published" : status === "archived" ? "Exam archived" : "Exam moved to draft")
|
||||
toast.success(status === "published" ? t("exam.actions.publishSuccess") : status === "archived" ? t("exam.actions.archiveSuccess") : t("exam.actions.draftSuccess"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || "Failed to update exam")
|
||||
toast.error(result.message || t("exam.actions.updateFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to update exam")
|
||||
toast.error(t("exam.actions.updateFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -119,14 +121,14 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
formData.set("examId", exam.id)
|
||||
const result = await duplicateExamAction(null, formData)
|
||||
if (result.success && result.data) {
|
||||
toast.success("Exam duplicated")
|
||||
toast.success(t("exam.actions.duplicateSuccess"))
|
||||
router.push(`/teacher/exams/${result.data}/build`)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || "Failed to duplicate exam")
|
||||
toast.error(result.message || t("exam.actions.duplicateFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to duplicate exam")
|
||||
toast.error(t("exam.actions.duplicateFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -139,14 +141,14 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
formData.set("examId", exam.id)
|
||||
const result = await deleteExamAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success("Exam deleted successfully")
|
||||
toast.success(t("exam.actions.deleteSuccess"))
|
||||
setShowDeleteDialog(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(result.message || "Failed to delete exam")
|
||||
toast.error(result.message || t("exam.actions.deleteFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("Failed to delete exam")
|
||||
toast.error(t("exam.actions.deleteFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -163,75 +165,74 @@ export function ExamActions({ exam }: ExamActionsProps) {
|
||||
e.stopPropagation()
|
||||
handleView()
|
||||
}}
|
||||
title="Preview Exam"
|
||||
aria-label="Preview exam"
|
||||
title={t("exam.actions.preview")}
|
||||
aria-label={t("exam.actions.preview")}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
<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
|
||||
"{exam.title}" 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,10 +254,10 @@ 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
|
||||
<ExamPaperPreview
|
||||
title={exam.title}
|
||||
subject={exam.subject}
|
||||
grade={exam.grade}
|
||||
@@ -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>
|
||||
|
||||
@@ -7,152 +7,154 @@ import { cn, formatDate } from "@/shared/lib/utils"
|
||||
import { Exam } from "../types"
|
||||
import { ExamActions } from "./exam-actions"
|
||||
|
||||
export const examColumns: ColumnDef<Exam>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 36,
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Exam Info",
|
||||
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}
|
||||
</Badge>
|
||||
))}
|
||||
{row.original.tags.length > 2 && (
|
||||
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">+{row.original.tags.length - 2}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground/80">{row.original.subject}</span>
|
||||
<span>•</span>
|
||||
<span>{row.original.grade}</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status
|
||||
// Use 'default' as base for published/success to ensure type safety,
|
||||
// but override with className below
|
||||
const variant: BadgeProps["variant"] =
|
||||
status === "published"
|
||||
? "default"
|
||||
: status === "archived"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={variant}
|
||||
className={cn(
|
||||
"capitalize",
|
||||
status === "published" && "bg-green-600 hover:bg-green-700 border-transparent",
|
||||
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}
|
||||
</Badge>
|
||||
)
|
||||
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={t("exam.actions.selectAll")}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label={t("exam.actions.selectRow")}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 36,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "stats",
|
||||
header: "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>•</span>
|
||||
<span>{row.original.totalScore} Pts</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{row.original.durationMin} min</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "difficulty",
|
||||
header: "Difficulty",
|
||||
cell: ({ row }) => {
|
||||
const diff = row.original.difficulty
|
||||
return (
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: t("exam.columns.examInfo"),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((level) => (
|
||||
<div
|
||||
key={level}
|
||||
className={cn(
|
||||
"h-1.5 w-3 rounded-full",
|
||||
level <= diff
|
||||
? diff <= 2 ? "bg-green-500" : diff === 3 ? "bg-yellow-500" : "bg-red-500"
|
||||
: "bg-muted"
|
||||
<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((tag, idx) => (
|
||||
<Badge key={`${tag}-${idx}`} variant="secondary" className="h-5 px-1.5 text-[10px]">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{row.original.tags.length > 2 && (
|
||||
<Badge variant="secondary" className="h-5 px-1.5 text-[10px]">+{row.original.tags.length - 2}</Badge>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium text-foreground/80">{row.original.subject}</span>
|
||||
<span>•</span>
|
||||
<span>{row.original.grade}</span>
|
||||
</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"}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dates",
|
||||
header: "Date",
|
||||
cell: ({ row }) => {
|
||||
const scheduled = row.original.scheduledAt
|
||||
const created = row.original.createdAt
|
||||
|
||||
return (
|
||||
<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="text-muted-foreground">{formatDate(scheduled)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground">Created</span>
|
||||
<span>{formatDate(created)}</span>
|
||||
</>
|
||||
)}
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: t("exam.columns.status"),
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status
|
||||
const variant: BadgeProps["variant"] =
|
||||
status === "published"
|
||||
? "default"
|
||||
: status === "archived"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={variant}
|
||||
className={cn(
|
||||
"capitalize",
|
||||
status === "published" && "bg-green-600 hover:bg-green-700 border-transparent",
|
||||
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"
|
||||
)}
|
||||
>
|
||||
{t(`exam.status.${status}`)}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "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} {t("exam.columns.questions")}</span>
|
||||
<span>•</span>
|
||||
<span>{row.original.totalScore} {t("exam.columns.points")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{row.original.durationMin} min</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => <ExamActions exam={row.original} />,
|
||||
},
|
||||
]
|
||||
{
|
||||
accessorKey: "difficulty",
|
||||
header: t("exam.columns.difficulty"),
|
||||
cell: ({ row }) => {
|
||||
const diff = row.original.difficulty
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-0.5">
|
||||
{[1, 2, 3, 4, 5].map((level) => (
|
||||
<div
|
||||
key={level}
|
||||
className={cn(
|
||||
"h-1.5 w-3 rounded-full",
|
||||
level <= diff
|
||||
? diff <= 2 ? "bg-green-500" : diff === 3 ? "bg-yellow-500" : "bg-red-500"
|
||||
: "bg-muted"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-medium">
|
||||
{t(`exam.difficulty.${diff}`)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dates",
|
||||
header: t("exam.columns.date"),
|
||||
cell: ({ row }) => {
|
||||
const scheduled = row.original.scheduledAt
|
||||
const created = row.original.createdAt
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 text-xs">
|
||||
{scheduled ? (
|
||||
<>
|
||||
<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">{t("exam.columns.created")}</span>
|
||||
<span>{formatDate(created)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => <ExamActions exam={row.original} />,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
getActiveClassStudentIdsForHomework,
|
||||
getClassTeacherById,
|
||||
getExamWithQuestionsForHomework,
|
||||
getHomeworkSubmissionForGrading,
|
||||
getHomeworkSubmissionForPermission,
|
||||
getTeacherAssignedSubjectIds,
|
||||
gradeHomeworkAnswers,
|
||||
@@ -71,20 +72,29 @@ export async function createHomeworkAssignmentAction(
|
||||
const classRow = await getClassTeacherById(input.classId)
|
||||
if (!classRow) return { success: false, message: "Class not found" }
|
||||
|
||||
const exam = await getExamWithQuestionsForHomework(input.sourceExamId)
|
||||
if (!exam) return { success: false, message: "Exam not found" }
|
||||
// 快速作业模式:无 sourceExamId 时创建纯文本作业(无题目)
|
||||
const isQuickAssignment = !input.sourceExamId
|
||||
|
||||
let exam: Awaited<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" }
|
||||
}
|
||||
const assignedSubjectSet = new Set(assignedSubjectIds)
|
||||
if (!exam.subjectId) {
|
||||
return { success: false, message: "Exam subject not set" }
|
||||
}
|
||||
if (!assignedSubjectSet.has(exam.subjectId)) {
|
||||
return { success: false, message: "Not assigned to this subject" }
|
||||
if (!isQuickAssignment && exam) {
|
||||
const assignedSubjectSet = new Set(assignedSubjectIds)
|
||||
if (!exam.subjectId) {
|
||||
return { success: false, message: "Exam subject not set" }
|
||||
}
|
||||
if (!assignedSubjectSet.has(exam.subjectId)) {
|
||||
return { success: false, message: "Not assigned to this subject" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,10 +122,10 @@ export async function createHomeworkAssignmentAction(
|
||||
|
||||
await createHomeworkAssignment({
|
||||
assignmentId,
|
||||
sourceExamId: input.sourceExamId,
|
||||
title: input.title?.trim().length ? input.title.trim() : exam.title,
|
||||
sourceExamId: input.sourceExamId ?? null,
|
||||
title: input.title?.trim().length ? input.title.trim() : (exam?.title ?? "Untitled Assignment"),
|
||||
description: input.description ?? null,
|
||||
structure: exam.structure,
|
||||
structure: exam?.structure ?? [],
|
||||
status: publish ? "published" : "draft",
|
||||
creatorId: ctx.userId,
|
||||
availableAt,
|
||||
@@ -124,7 +134,7 @@ export async function createHomeworkAssignmentAction(
|
||||
lateDueAt,
|
||||
maxAttempts: input.maxAttempts ?? 1,
|
||||
publish,
|
||||
questions: exam.questions,
|
||||
questions: exam?.questions ?? [],
|
||||
targetStudentIds,
|
||||
})
|
||||
|
||||
@@ -242,7 +252,7 @@ export async function gradeHomeworkSubmissionAction(
|
||||
formData: FormData
|
||||
): Promise<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) => ({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
formData.set("sourceExamId", examId)
|
||||
|
||||
if (mode === "exam") {
|
||||
formData.set("sourceExamId", examId)
|
||||
} else {
|
||||
formData.delete("sourceExamId")
|
||||
}
|
||||
formData.set("classId", classId)
|
||||
formData.set("allowLate", allowLate ? "true" : "false")
|
||||
formData.set("publish", "true")
|
||||
|
||||
setIsSubmitting(true)
|
||||
const result = await createHomeworkAssignmentAction(null, formData)
|
||||
setIsSubmitting(false)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
router.push("/teacher/homework/assignments")
|
||||
} else {
|
||||
toast.error(result.message || "Failed to create")
|
||||
toast.error(result.message || t("homework.form.createFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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,40 +152,54 @@ 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>{t("homework.form.sourceExam")}</Label>
|
||||
<Select value={examId} onValueChange={setExamId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("homework.form.selectExam")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{exams.map((e) => (
|
||||
<SelectItem key={e.id} value={e.id}>
|
||||
{e.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="sourceExamId" value={examId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label>Source Exam</Label>
|
||||
<Select value={examId} onValueChange={setExamId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an exam" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{exams.map((e) => (
|
||||
<SelectItem key={e.id} value={e.id}>
|
||||
{e.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input type="hidden" name="sourceExamId" value={examId} />
|
||||
<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="title">Assignment Title (optional)</Label>
|
||||
<Input id="title" name="title" placeholder="Defaults to exam title" />
|
||||
</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">
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Check,
|
||||
MessageSquarePlus,
|
||||
X,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Save,
|
||||
User,
|
||||
import {
|
||||
Check,
|
||||
MessageSquarePlus,
|
||||
X,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Save,
|
||||
User,
|
||||
AlertCircle,
|
||||
Clock
|
||||
} from "lucide-react"
|
||||
@@ -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()
|
||||
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,19 +336,19 @@ 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">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500 ease-in-out"
|
||||
style={{ width: `${Math.min(100, Math.max(0, progressPercent))}%` }}
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500 ease-in-out"
|
||||
style={{ width: `${Math.min(100, Math.max(0, progressPercent))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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,15 +427,15 @@ 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>
|
||||
</Button>
|
||||
|
||||
<div className="flex w-full items-center justify-between gap-2 pt-2">
|
||||
<div className="flex w-full items-center justify-between gap-2 pt-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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>
|
||||
|
||||
@@ -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 "Start Assignment" button above to begin. Your answers will be saved when you click "Save Answer".
|
||||
{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,33 +444,33 @@ 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 &&
|
||||
const hasAnswer = answersByQuestionId[q.questionId]?.answer !== undefined &&
|
||||
answersByQuestionId[q.questionId]?.answer !== "" &&
|
||||
(Array.isArray(answersByQuestionId[q.questionId]?.answer) ? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0 : true)
|
||||
|
||||
|
||||
return (
|
||||
<button
|
||||
key={q.questionId}
|
||||
@@ -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>
|
||||
@@ -490,14 +494,14 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
352
src/shared/i18n/messages/en/exam-homework.json
Normal file
352
src/shared/i18n/messages/en/exam-homework.json
Normal 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"
|
||||
}
|
||||
}
|
||||
352
src/shared/i18n/messages/zh-CN/exam-homework.json
Normal file
352
src/shared/i18n/messages/zh-CN/exam-homework.json
Normal 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": "已完成"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user