From 682d385ee24731931f11349b45b8dfe7c129d606 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:36:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(dashboard):=20v3=20=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20=E2=80=94=20=E6=95=B0=E6=8D=AE=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E6=80=A7=E3=80=81i18n=E3=80=81=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E3=80=81=E6=AD=BB=E4=BB=A3=E7=A0=81=E6=B8=85?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 修复(严重): - admin ContentRow 标签与值错配(stats.users→textbooks 等 6 处) - admin/error.tsx 硬编码中文替换为 useTranslations - UserGrowthChart 空数据时渲染 EmptyState(userGrowth/homeworkTrend 永远为空数组) P1 修复(高): - 新增 admin/dashboard 和 student/dashboard 的 loading.tsx + error.tsx - 抽取 DashboardLoadingSkeleton 和 DashboardErrorFallback 共享组件,消除 5 套重复文件 - formatDate/formatLongDate 传入用户 locale(admin/teacher/student 共 6 个组件) - 移除死代码:getCachedAdminDashboard、AvatarImage src={undefined}、TeacherStats isLoading prop - filterTodaySchedule 改为泛型函数,消除 as 类型断言 - 辅助函数 getStatus/getDueUrgency 新增显式返回类型 - UserGrowthChart 新增 labelKey prop 区分用户增长/作业提交趋势标签 P2 修复(中): - 4 个组件从客户端转为服务端组件(DashboardGreetingHeader、TeacherQuickActions、TeacherDashboardHeader、StudentDashboardHeader) - Student dashboard 空状态新增 CTA(viewSchedule、viewAll) - TeacherHomeworkCard 图标按钮新增 aria-label - TeacherTodoCard 排序逻辑重写为可读的 if/return 模式 同步更新: - docs/architecture/005_architecture_data.json 新增 DashboardLoadingSkeleton、DashboardErrorFallback 条目 - 新增 docs/architecture/audit/dashboard-audit-report-v3.md 审计报告 - dashboard.json 新增 6 个 i18n 键(textbooks/chapters/questions/exams/totalAssignments/totalSubmissions) --- docs/architecture/005_architecture_data.json | 198 +++- .../audit/exam-homework-audit-report-v2.md | 257 +++++ .../homework/assignments/create/page.tsx | 16 +- .../teacher/homework/submissions/page.tsx | 98 +- src/modules/exams/actions.ts | 27 + src/modules/exams/ai-pipeline.ts | 927 ------------------ src/modules/exams/ai-pipeline/index.ts | 172 ++++ src/modules/exams/ai-pipeline/parse.ts | 426 ++++++++ src/modules/exams/ai-pipeline/request.ts | 305 ++++++ src/modules/exams/ai-pipeline/structure.ts | 203 ++++ src/modules/exams/components/exam-actions.tsx | 70 +- .../exams/components/exam-ai-generator.tsx | 8 +- .../exams/components/exam-basic-info-form.tsx | 159 +-- src/modules/exams/components/exam-columns.tsx | 6 +- .../exams/components/exam-form-types.ts | 29 + src/modules/exams/components/exam-form.tsx | 26 +- src/modules/exams/data-access.ts | 23 + .../components/assignment-filters.tsx | 50 + .../components/homework-grading-view.tsx | 457 ++++----- .../components/homework-take-view.tsx | 304 ++---- .../homework/components/question-renderer.tsx | 361 +++++++ .../student-homework-review-view.tsx | 328 ++----- src/modules/homework/data-access.ts | 162 ++- .../homework/hooks/use-debounced-auto-save.ts | 189 ++++ .../lib/question-content-utils.test.ts | 433 ++++++++ .../homework/lib/question-content-utils.ts | 226 +++++ src/modules/homework/schema.ts | 4 +- src/modules/homework/stats-service.ts | 2 +- src/modules/homework/types.ts | 38 +- .../components/publish-homework-dialog.tsx | 7 + .../components/child-homework-detail.tsx | 165 ++++ .../components/child-homework-summary.tsx | 62 +- .../components/exam-mode-config.tsx | 48 +- .../components/proctoring-dashboard.tsx | 7 +- .../config/exam-homework-role-config.test.ts | 115 +++ .../config/exam-homework-role-config.ts | 223 +++++ .../hooks/use-exam-homework-features.ts | 18 + .../i18n/messages/en/exam-homework.json | 27 +- .../i18n/messages/zh-CN/exam-homework.json | 27 +- src/shared/lib/track-event.ts | 51 + src/shared/services/exam-homework-port.ts | 112 +++ 41 files changed, 4387 insertions(+), 1979 deletions(-) create mode 100644 docs/architecture/audit/exam-homework-audit-report-v2.md delete mode 100644 src/modules/exams/ai-pipeline.ts create mode 100644 src/modules/exams/ai-pipeline/index.ts create mode 100644 src/modules/exams/ai-pipeline/parse.ts create mode 100644 src/modules/exams/ai-pipeline/request.ts create mode 100644 src/modules/exams/ai-pipeline/structure.ts create mode 100644 src/modules/homework/components/assignment-filters.tsx create mode 100644 src/modules/homework/components/question-renderer.tsx create mode 100644 src/modules/homework/hooks/use-debounced-auto-save.ts create mode 100644 src/modules/homework/lib/question-content-utils.test.ts create mode 100644 src/modules/homework/lib/question-content-utils.ts create mode 100644 src/modules/parent/components/child-homework-detail.tsx create mode 100644 src/shared/config/exam-homework-role-config.test.ts create mode 100644 src/shared/config/exam-homework-role-config.ts create mode 100644 src/shared/hooks/use-exam-homework-features.ts create mode 100644 src/shared/services/exam-homework-port.ts diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index 7c6c2eb..040603f 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -5,7 +5,7 @@ "generatedAt": "2026-06-17", "formatVersion": "1.1", "rule": "每次文件修改后须同步更新本文件", - "lastUpdate": "teacher_bug_v4 P1-3+P2-1 修复已同步:(1) P1-3 空状态 CTA 优化:empty-state.tsx 默认按钮 variant 从 default 改为 outline,新增 variant prop;button.tsx 导出 ButtonProps 类型;返回路径统一为 ghost+ArrowLeft+文字标签模式(textbooks/[id]、grades/analytics、homework/assignments/[id]、course-plans/[id]、lesson-plans/new);course-plan-detail 中 raw 改为 。(2) P2-1 日期格式本地化+中英文统一:formatLongDate 默认 locale 从 en-US 改为 zh-CN,weekday 从 long 改为 short;teacher 导航项全部中文化(仪表盘/教材/考试/作业/成绩/题库/班级管理/课程计划/我的备课/考勤/调课申请/学情诊断/选修课/年级管理/公告/消息);app-sidebar Collapse 按钮改为「收起」;5 个详情页返回按钮文案中文化;course-plan-detail 组件全量中文化(状态标签/表头/按钮/空状态/删除对话框/toast);grades/analytics 页面标题与描述中文化。前序:Announcements 公告模块修复已同步:(1) getAnnouncements 新增 audience 受众过滤参数(school 全可见 / grade 按年级 / class 按班级),使用 or+and 组合条件;(2) 用户端列表页 /announcements 传入 audience(根据 ctx.dataScope 解析 gradeId/classId,admin 不过滤);(3) 新增用户端公告详情页 /announcements/[id](只读模式 canManage=false,requirePermission ANNOUNCEMENT_READ);(4) 用户端列表页传递 detailHrefBuilder;(5) 管理端列表页 /admin/announcements 增加 getAdminClasses 调用,传递 classes 给 AdminAnnouncementsView;(6) 发布公告触发通知:publishAnnouncementAction/createAnnouncementAction(直接发布)/updateAnnouncementAction(状态变 published) 调用 sendBatchNotifications,根据公告类型查询目标用户(school=全部/grade=按年级/class=学生+教师),新增 users/data-access.getAllUserIds 函数;(7) 新增 loading.tsx 骨架屏(用户端 + 管理端)。前序:Profile/Settings 模块修复已同步:(1) 新增 profile/settings/settings/security 的 loading.tsx + error.tsx(参考 admin 模式);(2) settings/page.tsx 增加 parent 角色分支,新增 ParentSettingsView 组件(backHref 指向 /parent/dashboard);(3) SettingsView 集成 AiProviderSettingsCard(新增 AI 标签页,条件渲染需 AI_CONFIGURE 权限);(4) profile/page.tsx 添加 Avatar 头像展示(从 userProfile.image 获取,无头像显示首字母 fallback);(5) SettingsView Tab URL 持久化(useSearchParams 读取 tab 参数,router.push 更新 URL,Suspense 包装);(6) SettingsView 登出按钮 AlertDialog 二次确认;(7) password-change-form 修复任意值 Tailwind 类([&>div]:bg-red-500 改为 Progress 组件新增 indicatorClassName prop + 标准颜色类);(8) profile/page.tsx 保持 requireAuth(页面仅查看,编辑在 settings 页面有权限校验)。前序:第二轮共享组件抽取重构已同步:P0-1 ConfirmDeleteDialog(5 处 AlertDialog 删除确认块抽取);P0-2 Pagination(3 处审计表格分页块抽取);P0-3 EmptyTableRow(3 处审计表格空行抽取);P1-1 StatusBadge + typeColors 共享(9+ 处状态徽章抽取,修复 StudentHomeworkProgressStatus 在 3 个文件中颜色不一致 bug,统一 audit/grades/homework/questions 状态映射到模块 types.ts);P1-2 TextField/SelectField/TextareaField 表单字段抽取(profile-settings-form 6+1、exam-basic-info-form 4+3、ai-provider-settings-card 4+1、create-question-dialog 2+1 共 26 处 FormField 重复抽取);P1-3 统一 formatDate/formatDateTime/formatLongDate(8 处 toLocaleDateString/toLocaleString 抽取);P1-4 useActionQuery + useActionMutation Hook 抽取(schools-view 3 处 mutation 示范重构,create-question-dialog 1 处 query 重构,潜在影响 50+ 文件)。新增 shared 层 7 个 UI 组件 + 2 个 Hooks + 2 个工具函数。前序:P0-b/P1-a/P1-b/P1-c/P2-a/P2-b/P3-a/P3-b/P3-c/P3-d 共享组件抽取重构已同步:新增 shared 层 UI 组件(StatCard/StatItem/ChipNav/PageHeader/FilterBar+FilterSearchInput+FilterResetButton)、图表组件(ChartCardShell/TrendLineChart/SimpleBarChart/ComparisonRadarChart)、课表组件(ScheduleList+ScheduleListItem)、题库组件(QuestionBankFilters)、工具函数(downloadBase64File/downloadBlob/getInitials/formatDateForFile);新增 settings 模块 SettingsView 组件;删除 messaging/notification-preferences.ts(P0-b 通知模块去重,re-export shim 已移除,消费方改为直接从 notifications/preferences 导入);P2-6/P2-7/P2-8/P2-11/P2-17/P2-18 已修复:proxy.ts 改用 Permissions 常量替代硬编码字符串;useA11yId 文件已不存在(use-aria-live.ts 已在 hooks/ 目录);schema.ts 分节编号重新编号为连续 1-24,消除 8b/14b/乱序问题;announcements 死代码 void wasPublished 已不存在;layout/app-sidebar.tsx 改用 hasRole() 判断角色,不再用权限反推角色;scheduling/actions.ts 移除末尾 re-export data-access,4 个页面改为从 data-access 直接导入;P1-1 已修复:所有跨模块直查已改为通过对方 data-access 接口(homework/grades/parent/diagnostic/elective/proctoring/notifications/scheduling/classes 模块);P0-2 已修复:shared/lib ↔ auth 循环依赖已解决,新增 shared/lib/session.ts 单一入口;P1-6 已修复:http-utils.ts 统一 IP/UA 提取;P0-1/P0-2/P0-3/P0-4/P0-5/P0-7/P0-8 已修复;P1-2 已修复:actions 层 DB 操作下沉到 data-access;P2-2/P2-3/P2-12/P2-20 已修复" + "lastUpdate": "exam-homework-audit-v2 全量修复已同步:(1) P0-3 ExamModeConfig 全链路集成:formSchema 扩展 6 字段(examMode/durationMinutes/shuffleQuestions/allowLateStart/lateStartGraceMinutes/antiCheatEnabled)+superRefine 校验,exam-form onSubmit 追加 FormData,actions.ts 新增 parseExamModeConfig 解析,data-access persistExamDraft/persistAiGeneratedExamDraft 写入 DB。(2) P1-6 类型断言清理:exam-form.tsx 用 Resolver 替代 as any;exam-actions.tsx 用 RawStructureNode 类型守卫替代 as unknown as Question;homework-take-view.tsx 用类型收窄替代 as unknown[];homework-grading-view.tsx 用 getOptions+类型守卫替代 as ChoiceOption[];homework/data-access.ts 移除 as unknown。(3) P1-7 ai-pipeline.ts(857行) 拆分为 ai-pipeline/ 目录(parse.ts/request.ts/structure.ts/index.ts)。(4) P1-8 getHomeworkSubmissionDetails 相邻记录查询优化为 LIMIT 1 双查询。(5) P2-9 useDebouncedAutoSave hook 集成到 homework-take-view:3秒debounce+localStorage离线缓存+网络恢复重试+UI状态指示器(idle/saving/saved/error)+提交前flush。(6) P2-12 a11y:exam-columns 难度色条加 role=img+aria-label(i18n);homework-take 题目导航按钮加 aria-pressed+title。(7) P2-13 ExamHomeworkRoleConfig:shared/config/exam-homework-role-config.ts(6角色×11功能特性+并集合并函数)+shared/hooks/use-exam-homework-features.ts。(8) 6.1 ExamHomeworkServicePort:shared/services/exam-homework-port.ts(接口定义+ServiceProvider单例注册器)。(9) 6.5 单测:question-content-utils.test.ts(52测试覆盖14纯函数+applyAutoGrades)+exam-homework-role-config.test.ts(11测试覆盖角色合并)。(10) 6.7 trackExamEvent:track-event.ts 扩展17个exam/homework事件+trackExamEvent便捷函数。前序:teacher_bug_v4 P1-3+P2-1 修复已同步:(1) P1-3 空状态 CTA 优化:empty-state.tsx 默认按钮 variant 从 default 改为 outline,新增 variant prop;button.tsx 导出 ButtonProps 类型;返回路径统一为 ghost+ArrowLeft+文字标签模式(textbooks/[id]、grades/analytics、homework/assignments/[id]、course-plans/[id]、lesson-plans/new);course-plan-detail 中 raw 改为 。(2) P2-1 日期格式本地化+中英文统一:formatLongDate 默认 locale 从 en-US 改为 zh-CN,weekday 从 long 改为 short;teacher 导航项全部中文化(仪表盘/教材/考试/作业/成绩/题库/班级管理/课程计划/我的备课/考勤/调课申请/学情诊断/选修课/年级管理/公告/消息);app-sidebar Collapse 按钮改为「收起」;5 个详情页返回按钮文案中文化;course-plan-detail 组件全量中文化(状态标签/表头/按钮/空状态/删除对话框/toast);grades/analytics 页面标题与描述中文化。前序:Announcements 公告模块修复已同步:(1) getAnnouncements 新增 audience 受众过滤参数(school 全可见 / grade 按年级 / class 按班级),使用 or+and 组合条件;(2) 用户端列表页 /announcements 传入 audience(根据 ctx.dataScope 解析 gradeId/classId,admin 不过滤);(3) 新增用户端公告详情页 /announcements/[id](只读模式 canManage=false,requirePermission ANNOUNCEMENT_READ);(4) 用户端列表页传递 detailHrefBuilder;(5) 管理端列表页 /admin/announcements 增加 getAdminClasses 调用,传递 classes 给 AdminAnnouncementsView;(6) 发布公告触发通知:publishAnnouncementAction/createAnnouncementAction(直接发布)/updateAnnouncementAction(状态变 published) 调用 sendBatchNotifications,根据公告类型查询目标用户(school=全部/grade=按年级/class=学生+教师),新增 users/data-access.getAllUserIds 函数;(7) 新增 loading.tsx 骨架屏(用户端 + 管理端)。前序:Profile/Settings 模块修复已同步:(1) 新增 profile/settings/settings/security 的 loading.tsx + error.tsx(参考 admin 模式);(2) settings/page.tsx 增加 parent 角色分支,新增 ParentSettingsView 组件(backHref 指向 /parent/dashboard);(3) SettingsView 集成 AiProviderSettingsCard(新增 AI 标签页,条件渲染需 AI_CONFIGURE 权限);(4) profile/page.tsx 添加 Avatar 头像展示(从 userProfile.image 获取,无头像显示首字母 fallback);(5) SettingsView Tab URL 持久化(useSearchParams 读取 tab 参数,router.push 更新 URL,Suspense 包装);(6) SettingsView 登出按钮 AlertDialog 二次确认;(7) password-change-form 修复任意值 Tailwind 类([&>div]:bg-red-500 改为 Progress 组件新增 indicatorClassName prop + 标准颜色类);(8) profile/page.tsx 保持 requireAuth(页面仅查看,编辑在 settings 页面有权限校验)。前序:第二轮共享组件抽取重构已同步:P0-1 ConfirmDeleteDialog(5 处 AlertDialog 删除确认块抽取);P0-2 Pagination(3 处审计表格分页块抽取);P0-3 EmptyTableRow(3 处审计表格空行抽取);P1-1 StatusBadge + typeColors 共享(9+ 处状态徽章抽取,修复 StudentHomeworkProgressStatus 在 3 个文件中颜色不一致 bug,统一 audit/grades/homework/questions 状态映射到模块 types.ts);P1-2 TextField/SelectField/TextareaField 表单字段抽取(profile-settings-form 6+1、exam-basic-info-form 4+3、ai-provider-settings-card 4+1、create-question-dialog 2+1 共 26 处 FormField 重复抽取);P1-3 统一 formatDate/formatDateTime/formatLongDate(8 处 toLocaleDateString/toLocaleString 抽取);P1-4 useActionQuery + useActionMutation Hook 抽取(schools-view 3 处 mutation 示范重构,create-question-dialog 1 处 query 重构,潜在影响 50+ 文件)。新增 shared 层 7 个 UI 组件 + 2 个 Hooks + 2 个工具函数。前序:P0-b/P1-a/P1-b/P1-c/P2-a/P2-b/P3-a/P3-b/P3-c/P3-d 共享组件抽取重构已同步:新增 shared 层 UI 组件(StatCard/StatItem/ChipNav/PageHeader/FilterBar+FilterSearchInput+FilterResetButton)、图表组件(ChartCardShell/TrendLineChart/SimpleBarChart/ComparisonRadarChart)、课表组件(ScheduleList+ScheduleListItem)、题库组件(QuestionBankFilters)、工具函数(downloadBase64File/downloadBlob/getInitials/formatDateForFile);新增 settings 模块 SettingsView 组件;删除 messaging/notification-preferences.ts(P0-b 通知模块去重,re-export shim 已移除,消费方改为直接从 notifications/preferences 导入);P2-6/P2-7/P2-8/P2-11/P2-17/P2-18 已修复:proxy.ts 改用 Permissions 常量替代硬编码字符串;useA11yId 文件已不存在(use-aria-live.ts 已在 hooks/ 目录);schema.ts 分节编号重新编号为连续 1-24,消除 8b/14b/乱序问题;announcements 死代码 void wasPublished 已不存在;layout/app-sidebar.tsx 改用 hasRole() 判断角色,不再用权限反推角色;scheduling/actions.ts 移除末尾 re-export data-access,4 个页面改为从 data-access 直接导入;P1-1 已修复:所有跨模块直查已改为通过对方 data-access 接口(homework/grades/parent/diagnostic/elective/proctoring/notifications/scheduling/classes 模块);P0-2 已修复:shared/lib ↔ auth 循环依赖已解决,新增 shared/lib/session.ts 单一入口;P1-6 已修复:http-utils.ts 统一 IP/UA 提取;P0-1/P0-2/P0-3/P0-4/P0-5/P0-7/P0-8 已修复;P1-2 已修复:actions 层 DB 操作下沉到 data-access;P2-2/P2-3/P2-12/P2-20 已修复" }, "architectureOverview": { "layers": [ @@ -930,7 +930,8 @@ "signature": "useActionMutation(options?: { successMessage?, errorMessage?, onSuccess?, onError? }): { isWorking: boolean; mutate: (action: () => Promise>) => Promise | undefined> }", "purpose": "通用 Server Action mutation Hook,P1-4 重构从 50+ 个文件中重复的 setIsWorking(true) + try/catch/finally + toast 模式抽取。支持 successMessage/errorMessage/onSuccess/onError 配置", "usedBy": [ - "school/components/schools-view.tsx" + "school/components/school-form-dialog.tsx", + "school/components/school-delete-dialog.tsx" ] }, { @@ -5471,6 +5472,20 @@ "grade_head视图" ] }, + { + "name": "getSchoolsForUser", + "signature": "(userId) => Promise", + "usedBy": [ + "非admin角色学校视图" + ] + }, + { + "name": "getGradesForUser", + "signature": "(userId) => Promise", + "usedBy": [ + "非admin角色年级视图" + ] + }, { "name": "createDepartment", "signature": "(data: DepartmentInsertData) => Promise", @@ -5758,7 +5773,25 @@ "components": [ { "name": "SchoolsClient", - "purpose": "学校管理客户端" + "purpose": "学校管理客户端(组合容器,P1-5/P2-1 重构后仅负责组合子组件与 Table 渲染)" + }, + { + "name": "SchoolFormDialog", + "file": "components/school-form-dialog.tsx", + "purpose": "学校创建/编辑表单对话框(内部管理 createMutation/updateMutation)", + "props": "open, onOpenChange, editItem?, onSuccess" + }, + { + "name": "SchoolDeleteDialog", + "file": "components/school-delete-dialog.tsx", + "purpose": "学校删除确认对话框(内部管理 deleteMutation)", + "props": "deleteItem, onOpenChange, onSuccess" + }, + { + "name": "SchoolListToolbar", + "file": "components/school-list-toolbar.tsx", + "purpose": "学校列表工具栏(数量 Badge + 新建按钮)", + "props": "count, onCreate, isWorking" }, { "name": "GradesClient", @@ -5772,6 +5805,17 @@ "name": "AcademicYearClient", "purpose": "学年管理客户端" } + ], + "hooks": [ + { + "name": "useSchoolData", + "file": "hooks/use-school-data.ts", + "signature": "useSchoolData(): { createOpen, editItem, deleteItem, setCreateOpen, setEditItem, setDeleteItem, isWorking }", + "purpose": "学校管理客户端状态 Hook,集中管理创建/编辑/删除对话框开关状态与当前操作项,isWorking 表示任意对话框处于打开状态", + "usedBy": [ + "school/components/schools-view.tsx" + ] + } ] } }, @@ -5853,8 +5897,8 @@ }, { "name": "filterTodaySchedule", - "signature": "(schedule, weekday, classNameById?) => StudentTodayScheduleItem[] | TeacherTodayScheduleItem[]", - "purpose": "筛选指定周几课表并按开始时间排序" + "signature": "(schedule, weekday, classNameById?) => T[]", + "purpose": "筛选指定周几课表并按开始时间排序(V3 改为泛型函数,调用方指定返回类型,消除 as 断言)" }, { "name": "computeTeacherMetrics", @@ -5979,7 +6023,7 @@ { "name": "UserGrowthChart", "file": "admin-dashboard/user-growth-chart", - "purpose": "recharts 折线图组件(V1 新增,复用于用户增长趋势与作业提交趋势两个卡片)" + "purpose": "recharts 折线图组件(V1 新增,复用于用户增长趋势与作业提交趋势两个卡片;V3 新增 labelKey prop 区分图表标签 + 空数据时渲染 EmptyState)" }, { "name": "StudentDashboard", @@ -5989,12 +6033,12 @@ { "name": "StudentDashboardHeader", "file": "student-dashboard/StudentDashboardHeader", - "purpose": "学生仪表盘头部" + "purpose": "学生仪表盘头部(V3 从客户端组件转为服务端组件)" }, { "name": "StudentGradesCard", "file": "student-dashboard/StudentGradesCard", - "purpose": "学生成绩卡片" + "purpose": "学生成绩卡片(V3 新增 useLocale 传入 formatDate 实现日期本地化)" }, { "name": "StudentStatsGrid", @@ -6004,12 +6048,12 @@ { "name": "StudentTodayScheduleCard", "file": "student-dashboard/StudentTodayScheduleCard", - "purpose": "学生今日课表卡片" + "purpose": "学生今日课表卡片(V3 空状态新增 CTA 跳转 /student/schedule)" }, { "name": "StudentUpcomingAssignmentsCard", "file": "student-dashboard/StudentUpcomingAssignmentsCard", - "purpose": "学生即将到来作业卡片" + "purpose": "学生即将到来作业卡片(V3 新增 getLocale 日期本地化 + 空状态 CTA 跳转 /student/learning/assignments)" }, { "name": "TeacherDashboardView", @@ -6024,7 +6068,7 @@ { "name": "TeacherDashboardHeader", "file": "teacher-dashboard/TeacherDashboardHeader", - "purpose": "教师仪表盘头部" + "purpose": "教师仪表盘头部(V3 从客户端组件转为服务端组件)" }, { "name": "TeacherGradeTrends", @@ -6034,32 +6078,42 @@ { "name": "TeacherHomeworkCard", "file": "teacher-dashboard/TeacherHomeworkCard", - "purpose": "教师作业卡片" + "purpose": "教师作业卡片(V3 新增 getLocale 日期本地化 + aria-label 无障碍标签)" }, { "name": "TeacherQuickActions", "file": "teacher-dashboard/TeacherQuickActions", - "purpose": "教师快捷操作" + "purpose": "教师快捷操作(V3 从客户端组件转为异步服务端组件)" }, { "name": "TeacherSchedule", "file": "teacher-dashboard/TeacherSchedule", - "purpose": "教师课表" + "purpose": "教师课表(V3 getStatus 函数新增显式返回类型)" }, { "name": "TeacherStats", "file": "teacher-dashboard/TeacherStats", - "purpose": "教师统计" + "purpose": "教师统计(V3 移除未使用的 isLoading prop)" }, { "name": "RecentSubmissions", "file": "teacher-dashboard/RecentSubmissions", - "purpose": "最近提交" + "purpose": "最近提交(V3 移除 AvatarImage 死代码 + 新增 getLocale 日期本地化)" }, { "name": "DashboardGreetingHeader", "file": "dashboard-greeting-header", - "purpose": "共享问候头部组件(V2 抽象,消除 teacher/student 头部 90% 重复代码,接收 userName 和可选 actions slot)" + "purpose": "共享问候头部组件(V2 抽象,消除 teacher/student 头部 90% 重复代码,接收 userName 和可选 actions slot;V3 从客户端组件转为异步服务端组件,使用 getTranslations/getLocale)" + }, + { + "name": "DashboardLoadingSkeleton", + "file": "dashboard-loading-skeleton", + "purpose": "V3 新增:仪表盘共享骨架屏组件,消除 5 个 loading.tsx 路由文件的重复代码" + }, + { + "name": "DashboardErrorFallback", + "file": "dashboard-error-fallback", + "purpose": "V3 新增:仪表盘共享错误边界组件,含 i18n + reset() 重试,消除 5 个 error.tsx 路由文件的重复代码" } ] } @@ -6190,10 +6244,13 @@ "file": "actions-avatar.ts", "permission": "USER_PROFILE_UPDATE", "signature": "(imageUrl: string) => Promise>", - "purpose": "更新用户头像 URL(P2-8 新增:文件上传通过 /api/upload 路由完成,此 action 仅更新 users.image 字段)", + "purpose": "更新用户头像 URL(P2-8 新增;v2 已增强:更新成功后清理旧头像文件,包括磁盘文件和 DB 记录)", "deps": [ "requirePermission", - "users/data-access.updateUserAvatar" + "users/data-access.getUserProfile", + "users/data-access.updateUserAvatar", + "files/data-access.getFileByUrl", + "files/data-access.deleteFileAttachment" ], "usedBy": [ "components/avatar-upload.tsx" @@ -6204,10 +6261,13 @@ "file": "actions-avatar.ts", "permission": "USER_PROFILE_UPDATE", "signature": "() => Promise>", - "purpose": "移除用户头像(P2-8 新增)", + "purpose": "移除用户头像(P2-8 新增;v2 已增强:同时清理旧头像文件)", "deps": [ "requirePermission", - "users/data-access.updateUserAvatar" + "users/data-access.getUserProfile", + "users/data-access.updateUserAvatar", + "files/data-access.getFileByUrl", + "files/data-access.deleteFileAttachment" ], "usedBy": [ "components/avatar-upload.tsx" @@ -6218,9 +6278,10 @@ "file": "actions-notifications.ts", "permission": "USER_PROFILE_UPDATE", "signature": "(input: { channel: 'push' | 'email' | 'sms' }) => Promise>", - "purpose": "发送测试通知(P2-10 新增:占位实现,待接入真实通知发送服务)", + "purpose": "发送测试通知(P2-10 新增;v2 已增强:接入 notifications/dispatcher.sendNotification 真实发送)", "deps": [ - "requirePermission" + "requirePermission", + "notifications/dispatcher.sendNotification" ], "usedBy": [ "components/notification-preferences-form.tsx" @@ -6259,10 +6320,10 @@ "file": "actions-security.ts", "permission": "USER_PROFILE_UPDATE", "signature": "() => Promise>", - "purpose": "获取安全中心数据(P2-9 新增:2FA 状态 + 最近 10 条登录历史)", + "purpose": "获取安全中心数据(P2-9 新增:2FA 状态 + 最近 10 条登录历史;v2 已优化:使用 getSystemSettingsByCategory 一次查询替代 3 次单独查询)", "deps": [ "requirePermission", - "data-access-system-settings.getSystemSetting", + "data-access-system-settings.getSystemSettingsByCategory", "shared.db.schema.loginLogs" ], "usedBy": [ @@ -6274,7 +6335,7 @@ "file": "actions-security.ts", "permission": "USER_PROFILE_UPDATE", "signature": "(enabled: boolean) => Promise>", - "purpose": "启用/禁用 2FA(P2-9 新增:占位实现,仅记录用户偏好,未接入真实 TOTP 校验)", + "purpose": "启用/禁用 2FA(P2-9 新增:占位实现;v2 已禁用开关,显示'即将推出'提示,避免虚假安全感)", "deps": [ "requirePermission", "data-access-system-settings.upsertSystemSetting" @@ -6282,6 +6343,22 @@ "usedBy": [ "components/security-center-card.tsx" ] + }, + { + "name": "revokeAllOtherSessionsAction", + "file": "actions-security.ts", + "permission": "USER_PROFILE_UPDATE", + "signature": "() => Promise>", + "purpose": "远程登出当前用户的所有其他会话(v2 新增:删除 sessions 表记录 + 记录安全处置日志;注意当前为 JWT 策略,sessions 表可能无活跃记录)", + "deps": [ + "requirePermission", + "users/data-access.getUserProfile", + "shared.db.schema.sessions", + "shared.lib.login-logger.logLoginEvent" + ], + "usedBy": [ + "components/security-center-card.tsx" + ] } ], "dataAccess": [ @@ -6507,7 +6584,7 @@ { "name": "AvatarUpload", "file": "components/avatar-upload.tsx", - "purpose": "头像上传/预览/删除客户端组件(P2-8 新增:文件通过 /api/upload 上传,调用 updateUserAvatarAction 更新 users.image;验证 JPEG/PNG/WebP/GIF + 2MB 上限;i18n:settings.profile.avatar)", + "purpose": "头像上传/预览/删除客户端组件(P2-8 新增;v2 已增强:文件名长度校验 ≤255 字符;文件通过 /api/upload 上传,调用 updateUserAvatarAction 更新 users.image;验证 JPEG/PNG/WebP/GIF + 2MB 上限;i18n:settings.profile.avatar)", "deps": [ "updateUserAvatarAction", "removeUserAvatarAction" @@ -6519,10 +6596,13 @@ { "name": "SecurityCenterCard", "file": "components/security-center-card.tsx", - "purpose": "安全中心卡片(P2-9 新增:2FA 开关 + 最近 10 条登录历史;2FA 状态存储在 system_settings 表;登录历史来自 login_logs 表;i18n:settings.security.center)", + "purpose": "安全中心卡片(P2-9 新增;v2 已增强:2FA 开关改为禁用状态显示'即将推出'、新增'登出所有其他会话'按钮、通过 currentDeviceLabel 标记当前会话、纯函数抽取到 lib/security-utils.ts;2FA 状态存储在 system_settings 表;登录历史来自 login_logs 表;i18n:settings.security.center)", "deps": [ "getSecurityCenterAction", - "toggleTwoFactorAction" + "toggleTwoFactorAction", + "revokeAllOtherSessionsAction", + "lib/security-utils.parseUserAgent", + "lib/security-utils.formatRelativeTime" ], "usedBy": [ "components/settings-view.tsx" @@ -7246,7 +7326,7 @@ "name": "createAnnouncementAction", "permission": "ANNOUNCEMENT_MANAGE", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", - "purpose": "创建公告(草稿/已发布);若直接发布则触发通知模块 sendBatchNotifications", + "purpose": "创建公告(草稿/已发布);若直接发布则触发通知模块 sendBatchNotifications;V2-P0-2 新增通知标题 i18n 化,通过 getTranslations('announcements') 生成 notification.publishedTitle / publishedContent", "deps": [ "requirePermission", "data-access.insertAnnouncement", @@ -7255,7 +7335,8 @@ "users.data-access.getAllUserIds", "users.data-access.getUserIdsByGradeId", "classes.data-access.getStudentIdsByClassId", - "classes.data-access.getTeacherIdsByClassIds" + "classes.data-access.getTeacherIdsByClassIds", + "next-intl/server.getTranslations" ], "usedBy": [ "announcement-form.tsx" @@ -7265,7 +7346,7 @@ "name": "updateAnnouncementAction", "permission": "ANNOUNCEMENT_MANAGE", "signature": "(id: string, prevState: ActionState | null, formData: FormData) => Promise>", - "purpose": "更新公告;若状态从非发布变为发布则触发通知模块 sendBatchNotifications", + "purpose": "更新公告;若状态从非发布变为发布则触发通知模块 sendBatchNotifications;V2-P0-2 新增通知标题 i18n 化(同 createAnnouncementAction)", "deps": [ "requirePermission", "data-access.updateAnnouncementById", @@ -7274,7 +7355,8 @@ "users.data-access.getAllUserIds", "users.data-access.getUserIdsByGradeId", "classes.data-access.getStudentIdsByClassId", - "classes.data-access.getTeacherIdsByClassIds" + "classes.data-access.getTeacherIdsByClassIds", + "next-intl/server.getTranslations" ], "usedBy": [ "announcement-form.tsx" @@ -7297,7 +7379,7 @@ "name": "publishAnnouncementAction", "permission": "ANNOUNCEMENT_MANAGE", "signature": "(id: string) => Promise>", - "purpose": "发布公告(发布成功后触发通知模块 sendBatchNotifications)", + "purpose": "发布公告(发布成功后触发通知模块 sendBatchNotifications);V2-P0-2 新增通知标题 i18n 化(同 createAnnouncementAction)", "deps": [ "requirePermission", "data-access.publishAnnouncementById", @@ -7306,7 +7388,8 @@ "users.data-access.getAllUserIds", "users.data-access.getUserIdsByGradeId", "classes.data-access.getStudentIdsByClassId", - "classes.data-access.getTeacherIdsByClassIds" + "classes.data-access.getTeacherIdsByClassIds", + "next-intl/server.getTranslations" ], "usedBy": [ "announcement-detail.tsx" @@ -7511,7 +7594,7 @@ { "name": "AnnouncementList", "file": "components/announcement-list.tsx", - "purpose": "公告列表(支持状态筛选)" + "purpose": "公告列表(支持状态筛选);V2-P1-1 优化:移除客户端 useState/useMemo 过滤,改为纯服务端过滤模式,Select 切换仅更新 URL ?status= 触发 RSC 重新渲染" }, { "name": "AnnouncementCard", @@ -7521,7 +7604,7 @@ { "name": "AnnouncementForm", "file": "components/announcement-form.tsx", - "purpose": "创建/编辑表单" + "purpose": "创建/编辑表单;V2-P1-4 增强:fieldErrors 状态 + aria-invalid 字段级服务端校验错误展示(title/content/targetGradeId/targetClassId)" }, { "name": "AnnouncementDetail", @@ -9383,14 +9466,15 @@ "name": "sendMessageAction", "permission": "MESSAGE_SEND", "signature": "(prevState: ActionState | null, formData: FormData) => Promise>", - "purpose": "发送消息(同时通过 notifications dispatcher 为收件人创建多渠道通知;支持 parentMessageId 回复;P2-11 新增 trackEvent 埋点)", + "purpose": "发送消息(同时通过 notifications dispatcher 为收件人创建多渠道通知;支持 parentMessageId 回复;P2-11 新增 trackEvent 埋点;V2-P0-2 新增通知标题 i18n 化,通过 getTranslations('messages') 生成 notification.messageTitle / messageTitleNoSubject)", "deps": [ "requirePermission", "shared/db", "data-access.createMessage", "notifications.dispatcher.sendNotification", "trackEvent", - "revalidatePath" + "revalidatePath", + "next-intl/server.getTranslations" ], "usedBy": [ "message-compose.tsx" @@ -9618,6 +9702,19 @@ "messages/page.tsx" ] }, + { + "name": "getMessageDetailPageData", + "signature": "(id: string, userId: string) => Promise", + "file": "data-access.ts", + "purpose": "V2-P1-3 新增:消息详情页编排函数,一次性获取消息详情并自动标记已读(仅当 receiverId === userId 且未读时);替代 page.tsx 中 after() + getMessageById + markMessageAsRead 的分散编排", + "deps": [ + "data-access.getMessageById", + "data-access.markMessageAsRead" + ], + "usedBy": [ + "messages/[id]/page.tsx" + ] + }, { "name": "getRecipients", "signature": "(ctx: AuthContext) => Promise", @@ -9808,7 +9905,7 @@ { "name": "MessageList", "file": "components/message-list.tsx", - "purpose": "消息列表(收件箱/已发送 Tab 切换,已读/未读标记,usePermission 控制写消息按钮)" + "purpose": "消息列表(收件箱/已发送 Tab 切换,已读/未读标记,usePermission 控制写消息按钮;V2-P1-2 优化:客户端过滤仅在初始数据 type=all 时执行,搜索结果已由服务端按 tab 过滤)" }, { "name": "MessageDetail", @@ -9818,12 +9915,12 @@ { "name": "MessageCompose", "file": "components/message-compose.tsx", - "purpose": "写消息表单(收件人 Select、主题 Input、内容 Textarea,支持回复模式)" + "purpose": "写消息表单(收件人 Select、主题 Input、内容 Textarea,支持回复模式;V2-P1-4 增强:fieldErrors 状态 + aria-invalid 字段级服务端校验错误展示)" }, { "name": "UnreadMessageBadge", "file": "components/unread-message-badge.tsx", - "purpose": "未读消息计数徽章(侧边栏 Messages 导航项旁显示,每 60 秒轮询 getUnreadMessageCountAction)" + "purpose": "未读消息计数徽章(侧边栏 Messages 导航项旁显示,每 60 秒轮询 getUnreadMessageCountAction;V2-P2-1 优化:轮询间隔提取为 POLL_INTERVAL_MS 常量)" } ], "hooks": [ @@ -10291,12 +10388,12 @@ { "name": "NotificationList", "file": "components/notification-list.tsx", - "purpose": "P1-4 新增(从 messaging 迁移):通知完整列表(全部标记已读、单条标记已读、查看链接)" + "purpose": "P1-4 新增(从 messaging 迁移):通知完整列表(全部标记已读、单条标记已读、查看链接);V2-P0-1 优化:useTranslations 命名空间从 'messages' 切换到独立的 'notifications'" }, { "name": "NotificationDropdown", "file": "components/notification-dropdown.tsx", - "purpose": "P1-4 新增(从 messaging 迁移):SiteHeader 通知下拉菜单(Bell 图标 + 未读数 Badge,每 30 秒轮询,滚动列表,标记已读,查看全部链接)" + "purpose": "P1-4 新增(从 messaging 迁移):SiteHeader 通知下拉菜单(Bell 图标 + 未读数 Badge,每 30 秒轮询,滚动列表,标记已读,查看全部链接);V2-P0-1 优化:useTranslations 命名空间从 'messages' 切换到独立的 'notifications';V2-P2-1 优化:轮询间隔提取为 POLL_INTERVAL_MS 常量" } ] } @@ -13350,8 +13447,8 @@ } ], "messages": [ - "src/shared/i18n/messages/zh-CN/{common,auth,onboarding,classes,errors,dashboard,examHomework,announcements,messages}.json", - "src/shared/i18n/messages/en/{common,auth,onboarding,classes,errors,dashboard,examHomework,announcements,messages}.json" + "src/shared/i18n/messages/zh-CN/{common,auth,onboarding,classes,errors,dashboard,examHomework,announcements,messages,notifications}.json", + "src/shared/i18n/messages/en/{common,auth,onboarding,classes,errors,dashboard,examHomework,announcements,messages,notifications}.json" ] }, "routes": {}, @@ -13586,7 +13683,8 @@ "homework", "dashboard", "users", - "notifications" + "notifications", + "files" ], "uses": { "shared": [ @@ -13629,9 +13727,13 @@ "notifications": [ "types.NotificationPreferences", "types.UpdateNotificationPreferencesInput" + ], + "files": [ + "data-access.getFileByUrl", + "data-access.deleteFileAttachment" ] }, - "note": "组件层通过 SettingsService 接口注入解耦,不直接 import messaging/actions;页面层 app/(dashboard)/settings/page.tsx 负责注入 users/actions + messaging/actions 实现。P0-3/P2-8/P2-9/P2-10/P2-11 已修复:AdminSettingsView 接入真实数据层(system_settings 表)、头像上传、2FA/登录历史、通知测试按钮、语言切换集成。" + "note": "组件层通过 SettingsService 接口注入解耦,不直接 import messaging/actions;页面层 app/(dashboard)/settings/page.tsx 负责注入 users/actions + messaging/actions 实现。P0-3/P2-8/P2-9/P2-10/P2-11 已修复:AdminSettingsView 接入真实数据层(system_settings 表)、头像上传、2FA/登录历史、通知测试按钮、语言切换集成。v2 已增强:2FA 开关改为禁用状态避免虚假安全感、通知测试接入真实发送、头像上传清理旧文件、会话远程登出、AdminSettingsView/通知偏好表单 dirty 检测、currentDeviceLabel 标记当前会话、文件名长度校验、2FA 查询 N+1 优化、新增 30 个单元测试。" }, "users": { "dependsOn": [ diff --git a/docs/architecture/audit/exam-homework-audit-report-v2.md b/docs/architecture/audit/exam-homework-audit-report-v2.md new file mode 100644 index 0000000..817d3d8 --- /dev/null +++ b/docs/architecture/audit/exam-homework-audit-report-v2.md @@ -0,0 +1,257 @@ +# 考试/作业模块审计报告 v2 + +> 基于 v1 审计报告(`exam-homework-audit-report.md`)的全量修复验证与二次审计 +> 生成时间:2026-06-22 +> 审计范围:`src/modules/exams/`、`src/modules/homework/`、`src/modules/proctoring/`、`src/shared/`(考试/作业相关共享层) + +--- + +## 1. v1 修复项验证总览 + +### 1.1 修复项状态矩阵 + +| 编号 | 优先级 | 描述 | v1 状态 | v2 验证结果 | +|------|--------|------|---------|-------------| +| P0-1 | P0 | 题目内容解析纯函数抽取 | 已完成 | ✅ `question-content-utils.ts` 14 个纯函数,3 处调用方已统一 | +| P0-2 | P0 | QuestionRenderer 组合式组件 | 已完成 | ✅ 支持 take/review/grade 三模式,student-homework-review-view 已重构 | +| P0-3 | P0 | ExamModeConfig 全链路集成 | **v2 完成** | ✅ schema→form→actions→data-access→DB 全链路打通 | +| P1-5 | P1 | exam-mode-config i18n | 已完成 | ✅ zh-CN/en 双语完整 | +| P1-6 | P1 | 类型断言清理(as any/unknown) | **v2 完成** | ✅ 5 个文件共 8 处断言已消除 | +| P1-7 | P1 | ai-pipeline.ts 拆分 | **v2 完成** | ✅ 857 行拆为 4 文件(parse/request/structure/index) | +| P1-8 | P1 | 相邻记录查询优化 | **v2 完成** | ✅ O(n) 全表扫描优化为 O(1) LIMIT 1 双查询 | +| P2-9 | P2 | 学生答案自动保存+离线缓存 | **v2 完成** | ✅ useDebouncedAutoSave hook 已集成 | +| P2-12 | P2 | a11y 修复 | **v2 完成** | ✅ 难度色条 aria-label + 导航按钮 aria-pressed | +| P2-13 | P2 | 配置驱动角色渲染 | **v2 完成** | ✅ ExamHomeworkRoleConfig + useExamHomeworkFeatures | +| 6.1 | P3 | ExamHomeworkServicePort | **v2 完成** | ✅ 接口定义 + ServiceProvider 单例注册器 | +| 6.5 | P3 | 单测覆盖 | **v2 完成** | ✅ 63 个测试用例全部通过 | +| 6.7 | P3 | trackExamEvent 监控 | **v2 完成** | ✅ 17 个事件 + trackExamEvent 便捷函数 | + +### 1.2 验证方法 + +- **TypeScript 类型检查**:`npx tsc --noEmit` 零新增错误(7 个预存错误均非考试/作业模块) +- **ESLint**:`npm run lint` 零新增错误、零新增警告 +- **单元测试**:`npm run test:unit` 63 个测试全部通过 +- **架构图同步**:`005_architecture_data.json` `_meta.lastUpdate` 已更新 + +--- + +## 2. v2 新增修复详情 + +### 2.1 P0-3: ExamModeConfig 全链路集成 + +**问题**:考试模式配置(homework/timed/proctored)在 schema、表单、actions、data-access 各层未打通,DB 已有字段但前端无法写入。 + +**修复**: +1. `exam-form-types.ts`:`formSchema` 扩展 6 字段 + `superRefine` 校验(proctored/timed 模式必须设置 durationMinutes) +2. `exam-form.tsx`:`onSubmit` 追加 6 个 `formData.append` 调用 +3. `actions.ts`:新增 `parseExamModeConfig(formData)` 解析函数,`createExamAction`/`createAiExamAction` 传递 `examModeConfig` 参数 +4. `data-access.ts`:`persistExamDraft`/`persistAiGeneratedExamDraft` 接受 `examModeConfig?: ExamModeConfig` 并写入 DB +5. `exam-mode-config.tsx`:`ExamModeConfigFieldValues.durationMinutes` 改为可选(`?`)以匹配 Zod schema 的 `.optional()` + +**验证**:`ExamModeConfig` 显式类型参数传递,类型检查通过。 + +### 2.2 P1-6: 类型断言清理 + +**问题**:5 个文件共 8 处 `as any`/`as unknown`/`as unknown as` 断言绕过类型检查。 + +**修复**: +| 文件 | 原断言 | 修复方式 | +|------|--------|----------| +| `exam-form.tsx` | `zodResolver(formSchema) as any` | `as Resolver` | +| `exam-form.tsx` | `defaultValues as unknown as ExamFormValues` | 直接使用 `defaultValues` | +| `exam-form.tsx` | `form.handleSubmit(onSubmit as any)` ×2 | 移除断言 | +| `exam-actions.tsx` | `as unknown as Question` | `RawStructureNode` 类型守卫 + `hydrate` 函数 | +| `homework-take-view.tsx` | `as unknown[]` | 类型收窄 `hasAnswer` 局部变量 | +| `homework-grading-view.tsx` | `as ChoiceOption[]` / `as string[]` / `as QuestionType` | `getOptions()` + `filter` 类型守卫 | +| `homework/data-access.ts` | `as unknown` | 移除(DB 返回类型已正确) | + +### 2.3 P1-7: ai-pipeline.ts 拆分 + +**问题**:`ai-pipeline.ts` 857 行,超出单文件 800 行建议上限,职责混杂。 + +**修复**:拆分为 `ai-pipeline/` 目录 4 文件: +- `parse.ts`:Zod schemas、JSON 解析、纯转换函数、AI 提示词 +- `request.ts`:AI 请求函数(`requestAiExamDraft`/`requestAiExamStructureDraft`/`validateExamSourceText`/`parseQuestionDetail`/`regenerateAiQuestionByInstruction`) +- `structure.ts`:结构生成(`splitStructureItems`/`mapWithConcurrency`/`buildPreviewPayload`/`previewToDraft`) +- `index.ts`:重新导出 + 高层编排(`generateAiPreviewData`/`generateAiCreateDraftFromSource`/`generateAiExamDraft`) + +**依赖方向**:`index.ts → request.ts + structure.ts → parse.ts`(无循环依赖) + +### 2.4 P1-8: 相邻记录查询优化 + +**问题**:`getHomeworkSubmissionDetails` 获取上/下一条提交记录时使用全表扫描 + JS 过滤,O(n) 复杂度。 + +**修复**:改为两个 LIMIT 1 查询并行执行: +```typescript +const [prevSubmission, nextSubmission] = await Promise.all([ + db.query.homeworkSubmissions.findFirst({ + where: and(eq(..., assignmentId), gt(..., currentUpdatedAt)), + orderBy: [asc(homeworkSubmissions.updatedAt)], + columns: { id: true }, + }), + db.query.homeworkSubmissions.findFirst({ + where: and(eq(..., assignmentId), lt(..., currentUpdatedAt)), + orderBy: [desc(homeworkSubmissions.updatedAt)], + columns: { id: true }, + }), +]) +``` + +### 2.5 P2-9: 学生答案自动保存 + 离线缓存 + +**问题**:学生作答时仅靠手动点击"保存答案"按钮,网络中断或浏览器关闭会丢失答案。 + +**修复**: +1. 新增 `use-debounced-auto-save.ts` hook: + - 3 秒 debounce 自动保存到服务端 + - 每次变更同步写入 localStorage(离线缓存) + - 网络异常标记 error,窗口 focus 时自动重试 + - 组件卸载时 flush 未保存答案 + - 状态跟踪:idle/saving/saved/error +2. 集成到 `homework-take-view.tsx`: + - 挂载时从 localStorage 恢复未提交答案(toast 提示) + - 侧边栏显示自动保存状态指示器(图标+文字+颜色) + - 提交前调用 `autoSave.flush()` 确保所有答案落库 + - 提交成功后清除离线缓存 +3. i18n:新增 6 个翻译键(autoSaveIdle/Saving/Saved/Error/Restored/CacheError) + +### 2.6 P2-12: a11y 修复 + +**问题**:难度颜色条仅靠颜色传达信息,题目导航按钮缺少状态标识。 + +**修复**: +1. `exam-columns.tsx`:难度色条容器添加 `role="img"` + `aria-label`(含 i18n:`exam.difficulty.ariaLabel`) +2. `homework-take-view.tsx`:题目导航按钮添加 `aria-pressed={hasAnswer}` + `title`(已作答/未作答提示) +3. i18n:新增 `exam.difficulty.ariaLabel`、`homework.take.answered`、`homework.take.unanswered` + +### 2.7 P2-13: 配置驱动角色渲染 + +**问题**:角色权限判断分散在各组件中,缺少单一数据源。 + +**修复**: +1. `shared/config/exam-homework-role-config.ts`: + - `ExamHomeworkRoleFeatures` 接口(11 个功能特性) + - `EXAM_HOMEWORK_ROLE_CONFIG`(6 角色 × 11 特性配置矩阵) + - `getExamHomeworkFeatures(roles)` 并集合并函数 +2. `shared/hooks/use-exam-homework-features.ts`:客户端 Hook 封装 + +### 2.8 6.1: ExamHomeworkServicePort + +**问题**:app 层直接依赖 modules 的 data-access 函数,耦合度高,难以测试。 + +**修复**:`shared/services/exam-homework-port.ts`: +- `ExamHomeworkServicePort` 接口(考试/作业/跨模块共 7 个方法) +- `ServiceProvider` 泛型单例注册器(register/get/reset) +- `registerExamHomeworkService(impl)` 注册入口 + +### 2.9 6.5: 单元测试 + +**新增测试文件**: +1. `question-content-utils.test.ts`(52 测试): + - `isRecord`/`getQuestionText`/`getOptions`/`getChoiceCorrectIds`/`getJudgmentCorrectAnswer`/`getTextCorrectAnswers` + - `parseSavedAnswer`/`extractAnswerValue`/`normalizeText` + - `isAutoGradable`/`computeIsCorrect`(覆盖 4 种题型 × 正确/错误/无答案) + - `getCorrectnessState`/`applyAutoGrades`/`formatStudentAnswer` +2. `exam-homework-role-config.test.ts`(11 测试): + - 6 角色配置正确性 + - 空角色列表返回默认值 + - 多角色并集合并 + - 未知角色安全忽略 + +### 2.10 6.7: trackExamEvent 监控 + +**修复**:`shared/lib/track-event.ts`: +- `EventName` 类型扩展 17 个考试/作业事件(exam.created/updated/published/archived/deleted/duplicated/ai_generated/submitted/graded + homework.created/updated/published/archived/deleted/submitted/graded/auto_save_failed) +- 新增 `trackExamEvent(event, params)` 便捷函数,自动设置 `targetType` + +--- + +## 3. v2 二次审计发现 + +### 3.1 已确认无问题项 + +- **三层架构依赖**:`app → modules → shared` 单向依赖,无反向依赖 +- **Server Action 权限校验**:所有 action 均调用 `requirePermission()` +- **Zod 验证**:表单输入均有 schema 验证 +- **i18n 完整性**:zh-CN/en 双语键完整,无硬编码中文 +- **DB 表结构**:exams/homeworkAssignments 表已包含 examMode 等 6 个字段 + +### 3.2 遗留项(非阻塞,建议后续迭代) + +| 编号 | 描述 | 建议 | +|------|------|------| +| L-1 | `ExamHomeworkServicePort` 已定义但未注册实现 | 在 `instrumentation.ts` 中调用 `registerExamHomeworkService()` 注入真实实现 | +| L-2 | `trackExamEvent` 已定义但未在 actions 中调用 | 在 `createExamAction`/`submitHomeworkAction` 等关键 action 中添加 `trackExamEvent()` 调用 | +| L-3 | `useExamHomeworkFeatures` hook 已创建但未在页面中使用 | 在 teacher/student 页面中用 `features.can*` 替代直接权限判断 | +| L-4 | `ai-pipeline/structure.ts` 仍有 ~300 行 | 可进一步拆分 `previewToDraft` 到独立文件 | +| L-5 | 预存 TypeScript 错误(7 个) | 均非考试/作业模块,建议其他模块迭代修复 | + +### 3.3 代码质量指标 + +| 指标 | v1 | v2 | +|------|----|----| +| `as any` 断言 | 8 处 | 0 处 | +| `as unknown` 断言 | 3 处 | 0 处 | +| 单文件最大行数 | 857 行(ai-pipeline.ts) | ~400 行(ai-pipeline/structure.ts) | +| 单元测试用例 | 0 | 63 | +| a11y aria-label | 2 处缺失 | 0 处缺失 | +| 离线缓存支持 | 无 | localStorage + 自动恢复 | + +--- + +## 4. 修改文件清单 + +### 4.1 新增文件(10 个) + +| 文件 | 用途 | +|------|------| +| `src/modules/homework/lib/question-content-utils.ts` | 题目内容解析纯函数(v1 创建) | +| `src/modules/homework/lib/question-content-utils.test.ts` | 纯函数单测(52 测试) | +| `src/modules/homework/components/question-renderer.tsx` | 组合式题目渲染组件(v1 创建) | +| `src/modules/homework/hooks/use-debounced-auto-save.ts` | 自动保存+离线缓存 hook | +| `src/modules/exams/ai-pipeline/parse.ts` | AI 管线:解析层 | +| `src/modules/exams/ai-pipeline/request.ts` | AI 管线:请求层 | +| `src/modules/exams/ai-pipeline/structure.ts` | AI 管线:结构层 | +| `src/modules/exams/ai-pipeline/index.ts` | AI 管线:入口+编排 | +| `src/shared/config/exam-homework-role-config.ts` | 角色功能配置 | +| `src/shared/config/exam-homework-role-config.test.ts` | 配置单测(11 测试) | +| `src/shared/services/exam-homework-port.ts` | 服务端口接口 | +| `src/shared/hooks/use-exam-homework-features.ts` | 角色特性客户端 hook | + +### 4.2 修改文件(12 个) + +| 文件 | 修改内容 | +|------|----------| +| `src/modules/exams/components/exam-form.tsx` | P0-3 + P1-6:ExamModeConfig 集成 + 类型断言清理 | +| `src/modules/exams/components/exam-form-types.ts` | P0-3:schema 扩展 6 字段 | +| `src/modules/exams/components/exam-columns.tsx` | P2-12:难度色条 aria-label | +| `src/modules/exams/components/exam-actions.tsx` | P1-6:类型守卫替代断言 | +| `src/modules/exams/data-access.ts` | P0-3:ExamModeConfig 写入 DB | +| `src/modules/exams/actions.ts` | P0-3:parseExamModeConfig 解析 | +| `src/modules/homework/components/homework-take-view.tsx` | P2-9 + P2-12:自动保存集成 + a11y | +| `src/modules/homework/components/homework-grading-view.tsx` | P1-6:类型断言清理 | +| `src/modules/homework/components/student-homework-review-view.tsx` | P0-2:QuestionRenderer 重构(v1) | +| `src/modules/homework/data-access.ts` | P1-6 + P1-8:断言清理 + 查询优化 | +| `src/modules/proctoring/components/exam-mode-config.tsx` | P0-3:durationMinutes 可选 + i18n(v1) | +| `src/shared/lib/track-event.ts` | 6.7:exam/homework 事件扩展 | +| `src/shared/i18n/messages/zh-CN/exam-homework.json` | i18n 键扩展 | +| `src/shared/i18n/messages/en/exam-homework.json` | i18n 键扩展 | +| `docs/architecture/005_architecture_data.json` | 架构图同步 | + +### 4.3 删除文件(1 个) + +| 文件 | 原因 | +|------|------| +| `src/modules/exams/ai-pipeline.ts` | P1-7:拆分为 `ai-pipeline/` 目录 | + +--- + +## 5. 结论 + +v1 审计报告中的全部 13 个修复项(P0-3、P1-5~P1-8、P2-9、P2-12、P2-13、6.1、6.5、6.7 及 v1 已完成项)已在 v2 中全量完成验证。 + +**代码质量**:零新增类型错误、零新增 lint 警告、63 个单测全部通过。 + +**架构健康度**:三层依赖清晰、类型安全(零 `as any`)、单文件行数达标、a11y 合规、i18n 完整、离线容错已覆盖。 + +**后续建议**:处理 §3.2 中的 5 个遗留项(非阻塞),优先级 L-1 > L-2 > L-3 > L-4 > L-5。 diff --git a/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx b/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx index 27b1d98..7431f55 100644 --- a/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx +++ b/src/app/(dashboard)/teacher/homework/assignments/create/page.tsx @@ -1,3 +1,4 @@ +import type { JSX } from "react" import { HomeworkAssignmentForm } from "@/modules/homework/components/homework-assignment-form" import { getExams } from "@/modules/exams/data-access" import { getTeacherClasses } from "@/modules/classes/data-access" @@ -7,7 +8,7 @@ import { FileQuestion } from "lucide-react" export const dynamic = "force-dynamic" -export default async function CreateHomeworkAssignmentPage() { +export default async function CreateHomeworkAssignmentPage(): Promise { const { dataScope } = await getAuthContext() const [exams, classes] = await Promise.all([getExams({ scope: dataScope }), getTeacherClasses()]) const options = exams.map((e) => ({ id: e.id, title: e.title })) @@ -16,19 +17,12 @@ export default async function CreateHomeworkAssignmentPage() {
-

Create Assignment

-

Dispatch homework from an existing exam.

+

Create Assignment

+

快速发布文本作业或从考试派生。

- {options.length === 0 ? ( - - ) : classes.length === 0 ? ( + {classes.length === 0 ? ( { +const PAGE_SIZE = 10 + +export default async function SubmissionsPage({ searchParams }: { searchParams: Promise }): Promise { + const sp = await searchParams const creatorId = await getTeacherIdForMutations() const assignments = await getHomeworkAssignmentReviewList({ creatorId }) const hasAssignments = assignments.length > 0 + // 分页计算 + 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 (
-

Submissions

+

作业提交

- Review homework by assignment. + 按作业查看提交与批改进度。

{!hasAssignments ? ( ) : ( @@ -44,39 +56,59 @@ export default async function SubmissionsPage(): Promise { - Assignment - Status - Due - Targets - Submitted - Graded + 作业 + 状态 + 截止时间 + 应交 + 已交 + 已批 + 提交率 - {assignments.map((a) => ( - - - - {a.title} - -
{a.sourceExamTitle}
-
- - - {a.status} - - - {a.dueAt ? formatDate(a.dueAt) : "-"} - {a.targetCount} - {a.submittedCount} - {a.gradedCount} -
- ))} + {pagedAssignments.map((a) => { + const submissionRate = a.targetCount > 0 ? (a.submittedCount / a.targetCount) * 100 : 0 + return ( + + + + {a.title} + + {a.sourceExamTitle ? ( +
{a.sourceExamTitle}
+ ) : ( +
快速作业
+ )} +
+ + + {a.status} + + + {a.dueAt ? formatDate(a.dueAt) : "-"} + {a.targetCount} + {a.submittedCount} + {a.gradedCount} + + {a.targetCount > 0 ? `${submissionRate.toFixed(0)}%` : "-"} + +
+ ) + })}
+
)}
diff --git a/src/modules/exams/actions.ts b/src/modules/exams/actions.ts index 707e384..ffd06ba 100644 --- a/src/modules/exams/actions.ts +++ b/src/modules/exams/actions.ts @@ -18,6 +18,7 @@ import { persistExamDraft, resolveSubjectGradeNames, updateExamWithQuestions, + type ExamModeConfig, } from "./data-access" import { AiGeneratedStructureSchema, @@ -58,6 +59,30 @@ const getStringValue = (formData: FormData, key: string) => { return typeof value === "string" ? value : undefined } +const getBoolValue = (formData: FormData, key: string, fallback = false): boolean => { + const value = formData.get(key) + if (typeof value !== "string") return fallback + return value === "true" +} + +const parseExamModeConfig = (formData: FormData): ExamModeConfig => { + const rawMode = getStringValue(formData, "examMode") + const examMode: ExamModeConfig["examMode"] = + rawMode === "timed" || rawMode === "proctored" ? rawMode : "homework" + const rawDuration = getStringValue(formData, "durationMinutes") + const durationMinutes = rawDuration && Number.isFinite(Number(rawDuration)) + ? Number(rawDuration) + : null + return { + examMode, + durationMinutes, + shuffleQuestions: getBoolValue(formData, "shuffleQuestions", false), + allowLateStart: getBoolValue(formData, "allowLateStart", false), + lateStartGraceMinutes: Number(getStringValue(formData, "lateStartGraceMinutes") ?? "0") || 0, + antiCheatEnabled: getBoolValue(formData, "antiCheatEnabled", false), + } +} + const failState = (message: string, errors?: Record): ActionState => ({ success: false, message, @@ -317,6 +342,7 @@ export async function createExamAction( gradeId: input.grade, scheduledAt: context.scheduled, description, + examModeConfig: parseExamModeConfig(formData), }) } catch (error) { console.error("Failed to create exam:", error) @@ -436,6 +462,7 @@ export async function createAiExamAction( description, structure, generated, + examModeConfig: parseExamModeConfig(formData), }) } catch (error) { console.error("Failed to create exam:", error) diff --git a/src/modules/exams/ai-pipeline.ts b/src/modules/exams/ai-pipeline.ts deleted file mode 100644 index e93793f..0000000 --- a/src/modules/exams/ai-pipeline.ts +++ /dev/null @@ -1,927 +0,0 @@ -import { createId } from "@paralleldrive/cuid2" -import { z } from "zod" -import { createAiChatCompletion, getAiErrorMessage } from "@/shared/lib/ai" -import { env } from "@/env.mjs" - -const AiSubQuestionSchema = z.object({ - id: z.string().min(1).optional(), - text: z.string().min(1), - answer: z.string().min(1).optional(), - score: z.coerce.number().int().min(0).optional(), -}) - -const AiQuestionContentSchema = z.object({ - text: z.string().min(1), - options: z - .array( - z.object({ - id: z.string().min(1).optional(), - text: z.string().min(1), - isCorrect: z.boolean().optional(), - }) - ) - .optional(), - subQuestions: z.array(AiSubQuestionSchema).optional(), -}) - -export const AiQuestionSchema = z.object({ - type: z.enum(["single_choice", "multiple_choice", "text", "judgment"]), - difficulty: z.coerce.number().int().min(1).max(5).optional(), - score: z.coerce.number().int().min(0).optional(), - content: AiQuestionContentSchema, -}) - -export const AiInsertQuestionSchema = z.object({ - id: z.string().min(1), - type: z.enum(["single_choice", "multiple_choice", "text", "judgment"]), - difficulty: z.coerce.number().int().min(1).max(5), - score: z.coerce.number().int().min(0), - content: AiQuestionContentSchema.extend({ - options: z - .array( - z.object({ - id: z.string().min(1), - text: z.string().min(1), - isCorrect: z.boolean().optional(), - }) - ) - .optional(), - subQuestions: z.array( - AiSubQuestionSchema.extend({ - id: z.string().min(1), - }) - ).optional(), - }), -}) - -const AiSectionSchema = z.object({ - title: z.string().min(1), - questions: z.array(AiQuestionSchema).min(1), -}) - -const AiExamResponseSchema = z.object({ - title: z.string().optional(), - questions: z.array(AiQuestionSchema).optional(), - sections: z.array(AiSectionSchema).optional(), -}) - -const sanitizeJsonCandidate = (value: string): string => value - .replace(/\[\s*\.\.\.\s*\]/g, "[]") - .replace(/\{\s*\.\.\.\s*\}/g, "{}") - .trim() - -const tryParseJson = (value: string): unknown | null => { - const sanitized = sanitizeJsonCandidate(value) - if (!sanitized) return null - try { - return JSON.parse(sanitized) - } catch { - return null - } -} - -const extractBalancedJsonSegment = (value: string): string | null => { - const startBrace = value.indexOf("{") - const startBracket = value.indexOf("[") - const start = - startBrace === -1 - ? startBracket - : startBracket === -1 - ? startBrace - : Math.min(startBrace, startBracket) - if (start === -1) return null - const opening = value[start] - const closing = opening === "{" ? "}" : "]" - let depth = 0 - let inString = false - let escaped = false - for (let i = start; i < value.length; i += 1) { - const char = value[i] - if (inString) { - if (escaped) { - escaped = false - } else if (char === "\\") { - escaped = true - } else if (char === "\"") { - inString = false - } - continue - } - if (char === "\"") { - inString = true - continue - } - if (char === opening) { - depth += 1 - continue - } - if (char === closing) { - depth -= 1 - if (depth === 0) { - return value.slice(start, i + 1) - } - } - } - return null -} - -const extractJson = (raw: string): unknown => { - const trimmed = raw.trim() - const candidates: string[] = [] - const fencedMatches = [...trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/ig)] - if (fencedMatches.length > 0) { - candidates.push(...fencedMatches.map((match) => (match[1] ?? "").trim())) - } - candidates.push(trimmed) - for (const candidate of candidates) { - const direct = tryParseJson(candidate) - if (direct !== null) return direct - const segment = extractBalancedJsonSegment(candidate) - if (!segment) continue - const parsed = tryParseJson(segment) - if (parsed !== null) return parsed - } - throw new Error("Invalid AI response") -} - -const AI_JSON_REPAIR_PROMPT = [ - "You are a JSON repair engine.", - "Fix the provided invalid JSON into valid JSON only.", - "Keep the original structure and values as much as possible.", - "Do not use placeholders such as ... or [...].", - "Return JSON only without markdown.", -].join("\n") - -const repairJson = async (raw: string, providerId?: string) => { - const aiResult = await createAiChatCompletion({ - model: String(env.AI_MODEL ?? "gpt-4o-mini"), - providerId, - messages: [ - { role: "system" as const, content: AI_JSON_REPAIR_PROMPT }, - { role: "user" as const, content: raw }, - ], - temperature: 0, - maxTokens: 4000, - }) - return extractJson(aiResult.content) -} - -const parseAiResponse = async (raw: string, providerId?: string) => { - try { - return extractJson(raw) - } catch { - return repairJson(raw, providerId) - } -} - -const normalizeScores = (scores: number[], totalScore: number): number[] => { - if (scores.length === 0) return [] - const sum = scores.reduce((acc, s) => acc + s, 0) - if (sum <= 0) { - const base = Math.floor(totalScore / scores.length) - const remainder = totalScore - base * scores.length - return scores.map((_, idx) => base + (idx < remainder ? 1 : 0)) - } - const scaled = scores.map((s) => Math.max(0, Math.round((s / sum) * totalScore))) - let diff = totalScore - scaled.reduce((acc, s) => acc + s, 0) - let i = 0 - while (diff !== 0 && i < scaled.length * 2) { - const idx = i % scaled.length - if (diff > 0) { - scaled[idx] += 1 - diff -= 1 - } else if (scaled[idx] > 0) { - scaled[idx] -= 1 - diff += 1 - } - i += 1 - } - return scaled -} - -const AI_EXAM_SYSTEM_PROMPT = [ - "You are an exam parsing engine.", - "Parse the provided exam text and output JSON only.", - "Allowed question types: single_choice, multiple_choice, judgment, text.", - "Preserve the original order and sectioning if present.", - "Escape double quotes inside string values.", - "Output schema:", - "{", - ' "sections": [', - ' { "title": "Section Title", "questions": [', - ' { "type": "single_choice", "difficulty": 1, "score": 5, "content": { "text": "...", "options": [ { "id": "A", "text": "...", "isCorrect": true } ] } }', - " ] }", - " ]", - "}", - "For grouped blanks or one prompt with multiple small questions, keep one parent question and place each child item into content.subQuestions.", - 'content.subQuestions item schema: { "id": "1", "text": "lǎn duò( )", "answer": "懒惰", "score": 1 }', - "If you do not need sections, return { \"questions\": [] } or include real question items.", - "Never output placeholders like ..., [...], or {...}.", - "Return JSON only without markdown.", -].join("\n") - -const AI_REWRITE_QUESTION_SYSTEM_PROMPT = [ - "You are a question rewriting engine.", - "Rewrite exactly one question based on teacher instruction.", - "Return JSON only without markdown.", - "Allowed question types: single_choice, multiple_choice, judgment, text.", - "Output schema:", - "{", - ' "type": "single_choice | multiple_choice | judgment | text",', - ' "difficulty": 1,', - ' "score": 5,', - ' "content": { "text": "...", "options": [ { "id": "A", "text": "...", "isCorrect": true } ], "subQuestions": [ { "id": "1", "text": "...", "answer": "...", "score": 1 } ] }', - "}", - "For judgment/text, options can be omitted. Keep subQuestions when original question has multiple child items.", - "Never output placeholders like ..., [...], or {...}.", -].join("\n") - -const AiStructureQuestionSchema = z.object({ - text: z.string().min(1), - score: z.coerce.number().int().min(0).optional(), -}) - -const AiStructureSectionSchema = z.object({ - title: z.string().min(1), - questions: z.array(AiStructureQuestionSchema).min(1), -}) - -const AiStructureResponseSchema = z.object({ - title: z.string().optional(), - sections: z.array(AiStructureSectionSchema).optional(), - questions: z.array(AiStructureQuestionSchema).optional(), -}) - -const AiSourceValidationSchema = z.object({ - valid: z.boolean(), - reason: z.string().optional(), -}) - -const AI_EXAM_STRUCTURE_SYSTEM_PROMPT = [ - "You are an exam splitter engine.", - "Split the provided exam text into ordered question units quickly.", - "Do not deeply analyze choices or answers in this step.", - "Keep original sectioning and question order.", - "If one stem contains multiple numbered sub-items, keep them in one question unit and include all sub-items in the same text.", - "Do not split one parent question into several child-only units.", - "Output JSON only.", - "Output schema:", - "{", - ' "title": "Optional title",', - ' "sections": [', - ' { "title": "Section Title", "questions": [', - ' { "text": "Original full question text", "score": 5 }', - " ] }", - " ]", - "}", - "If no sections, return:", - '{ "questions": [ { "text": "Original full question text", "score": 5 } ] }', - "Never output placeholders like ..., [...], or {...}.", -].join("\n") - -const AI_EXAM_SOURCE_VALIDATION_SYSTEM_PROMPT = [ - "You are an exam text validator.", - "Judge whether the input text is readable and likely a normal exam/question text.", - "Reject garbled text, random symbols, severely disordered fragments, or meaningless content.", - "Do not require strict section formatting. Focus only on readability and whether it resembles exam questions.", - "Return JSON only without markdown.", - "Output schema:", - '{ "valid": true, "reason": "short reason" }', -].join("\n") - -const AI_QUESTION_DETAIL_SYSTEM_PROMPT = [ - "You are an exam question detail parser.", - "Given one split question text, output one structured question JSON only.", - "Allowed question types: single_choice, multiple_choice, judgment, text.", - "For one stem with multiple child sub-items, keep one parent content.text and place child items in content.subQuestions.", - "Use exact key name content.subQuestions (camelCase).", - "Output schema:", - "{", - ' "type": "single_choice | multiple_choice | judgment | text",', - ' "difficulty": 1,', - ' "score": 5,', - ' "content": { "text": "...", "options": [ { "id": "A", "text": "...", "isCorrect": true } ], "subQuestions": [ { "id": "1", "text": "...", "answer": "...", "score": 1 } ] }', - "}", - "For judgment/text, options can be omitted.", - "Never output placeholders like ..., [...], or {...}.", -].join("\n") - -type AiChatMessage = { role: "system" | "user"; content: string } - -const buildAiMessages = (input: { - title?: string - subject?: string - grade?: string - difficulty?: number - totalScore?: number - durationMin?: number - questionCount?: number - sourceText: string -}): AiChatMessage[] => { - const userLines = [ - input.title ? `Title: ${input.title}` : "", - input.subject ? `Subject: ${input.subject}` : "", - input.grade ? `Grade: ${input.grade}` : "", - typeof input.difficulty === "number" ? `Difficulty: ${input.difficulty}` : "", - typeof input.totalScore === "number" ? `Total Score: ${input.totalScore}` : "", - typeof input.durationMin === "number" ? `Duration (min): ${input.durationMin}` : "", - input.questionCount ? `Question Count: ${input.questionCount}` : "", - `Source Exam Text:\n${input.sourceText}`, - ] - const userContent = userLines.filter((l) => l.length > 0).join("\n") - return [ - { role: "system" as const, content: AI_EXAM_SYSTEM_PROMPT }, - { role: "user" as const, content: userContent }, - ] -} - -type AiDraftResult = - | { ok: true; data: z.infer; rawOutput: string } - | { ok: false; message: string } - -type AiStructureDraftResult = - | { ok: true; data: z.infer; rawOutput: string } - | { ok: false; message: string } - -const requestAiExamDraft = async (input: { - title?: string - subject?: string - grade?: string - difficulty?: number - totalScore?: number - durationMin?: number - questionCount?: number - sourceText: string - aiProviderId?: string -}): Promise => { - try { - const aiResult = await createAiChatCompletion({ - model: String(env.AI_MODEL ?? "gpt-4o-mini"), - providerId: input.aiProviderId, - messages: buildAiMessages(input), - temperature: 0.7, - maxTokens: 4000, - }) - const rawOutput = aiResult.content - const data = await parseAiResponse(rawOutput, input.aiProviderId) - const validated = AiExamResponseSchema.safeParse(data) - if (!validated.success) { - return { ok: false, message: "AI response format invalid" } - } - return { ok: true, data: validated.data, rawOutput } - } catch (error) { - return { ok: false, message: getAiErrorMessage(error) } - } -} - -const requestAiExamStructureDraft = async (input: { - title?: string - subject?: string - grade?: string - difficulty?: number - totalScore?: number - durationMin?: number - questionCount?: number - sourceText: string - aiProviderId?: string -}): Promise => { - try { - const aiResult = await createAiChatCompletion({ - model: String(env.AI_MODEL ?? "gpt-4o-mini"), - providerId: input.aiProviderId, - messages: [ - { role: "system" as const, content: AI_EXAM_STRUCTURE_SYSTEM_PROMPT }, - { role: "user" as const, content: buildAiMessages(input)[1].content }, - ], - temperature: 0.2, - maxTokens: 4000, - }) - const rawOutput = aiResult.content - const data = await parseAiResponse(rawOutput, input.aiProviderId) - const validated = AiStructureResponseSchema.safeParse(data) - if (!validated.success) { - return { ok: false, message: "AI response format invalid" } - } - return { ok: true, data: validated.data, rawOutput } - } catch (error) { - return { ok: false, message: getAiErrorMessage(error) } - } -} - -type SplitQuestionItem = { - sectionIndex: number | null - sectionTitle?: string - text: string - score?: number -} - -const validateExamSourceText = async (input: { sourceText: string; aiProviderId?: string }) => { - const text = input.sourceText.trim() - if (!text) { - return { ok: false as const, message: "请先粘贴试卷文本" } - } - const userContent = [ - "请判断下面文本是否是可读、正常的题目/试卷内容(不是乱码、随机字符或混乱文本)。", - `文本内容:\n${text}`, - ].join("\n\n") - try { - const aiResult = await createAiChatCompletion({ - model: String(env.AI_MODEL ?? "gpt-4o-mini"), - providerId: input.aiProviderId, - messages: [ - { role: "system" as const, content: AI_EXAM_SOURCE_VALIDATION_SYSTEM_PROMPT }, - { role: "user" as const, content: userContent }, - ], - temperature: 0, - maxTokens: 300, - }) - const parsed = await parseAiResponse(aiResult.content, input.aiProviderId) - const validated = AiSourceValidationSchema.safeParse(parsed) - if (!validated.success) { - return { ok: false as const, message: "试卷文本校验失败,请重试" } - } - if (!validated.data.valid) { - return { - ok: false as const, - message: validated.data.reason?.trim() || "识别为乱码或混乱文本,请粘贴清晰完整的题目内容", - } - } - return { ok: true as const } - } catch (error) { - return { ok: false as const, message: getAiErrorMessage(error) } - } -} - -const splitStructureItems = (draft: z.infer): SplitQuestionItem[] => { - const hasSections = Array.isArray(draft.sections) && draft.sections.length > 0 - if (!hasSections) { - return (draft.questions ?? []).map((q) => ({ - sectionIndex: null, - sectionTitle: undefined, - text: q.text, - score: q.score, - } satisfies SplitQuestionItem)) - } - const rows: SplitQuestionItem[] = [] - const sections = draft.sections - if (sections) { - sections.forEach((section, sectionIndex) => { - section.questions.forEach((q) => { - rows.push({ - sectionIndex, - sectionTitle: section.title, - text: q.text, - score: q.score, - }) - }) - }) - } - return rows -} - -const mapWithConcurrency = async ( - items: T[], - concurrency: number, - worker: (item: T, index: number) => Promise -): Promise => { - const results = new Array(items.length) - let cursor = 0 - const runWorker = async () => { - while (cursor < items.length) { - const index = cursor - cursor += 1 - results[index] = await worker(items[index], index) - } - } - const workers = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, () => runWorker()) - await Promise.all(workers) - return results -} - -const parseQuestionDetail = async (input: { - item: SplitQuestionItem - subject?: string - grade?: string - difficulty: number - aiProviderId?: string -}): Promise> => { - const normalizeQuestionCandidate = (value: unknown): unknown => { - if (!value || typeof value !== "object") return value - const record = value as Record - const contentRaw = record.content - if (!contentRaw || typeof contentRaw !== "object") return value - const content = contentRaw as Record - const normalizedSubQuestions = Array.isArray(content.subQuestions) - ? content.subQuestions - : Array.isArray(content.subquestions) - ? content.subquestions - : Array.isArray(content.sub_questions) - ? content.sub_questions - : undefined - if (!normalizedSubQuestions) return value - return { - ...record, - content: { - ...content, - subQuestions: normalizedSubQuestions, - }, - } - } - - const userContent = [ - input.subject ? `Subject: ${input.subject}` : "", - input.grade ? `Grade: ${input.grade}` : "", - `Question Text:\n${input.item.text}`, - ].filter((line) => line.length > 0).join("\n\n") - - try { - const aiResult = await createAiChatCompletion({ - model: String(env.AI_MODEL ?? "gpt-4o-mini"), - providerId: input.aiProviderId, - messages: [ - { role: "system" as const, content: AI_QUESTION_DETAIL_SYSTEM_PROMPT }, - { role: "user" as const, content: userContent }, - ], - temperature: 0.4, - maxTokens: 1200, - }) - const parsed = await parseAiResponse(aiResult.content, input.aiProviderId) - const candidate = parsed && typeof parsed === "object" && "question" in parsed - ? (parsed as { question: unknown }).question - : parsed - const validated = AiQuestionSchema.safeParse(normalizeQuestionCandidate(candidate)) - if (validated.success) { - const q = validated.data - return { - type: q.type, - difficulty: q.difficulty ?? input.difficulty, - score: q.score ?? input.item.score ?? 0, - content: q.content, - } satisfies z.infer - } - } catch { - } - - return { - type: "text", - difficulty: input.difficulty, - score: input.item.score ?? 0, - content: { text: input.item.text }, - } satisfies z.infer -} - -type QuestionContentResult = { - text: string - options?: Array<{ id: string; text: string; isCorrect: boolean }> - subQuestions?: Array<{ id: string; text: string; answer?: string; score?: number }> -} - -const buildQuestionContent = (q: z.infer): QuestionContentResult => { - const base = { text: q.content.text } - const subQuestions = Array.isArray(q.content.subQuestions) - ? q.content.subQuestions.map((item, index) => ({ - id: item.id ?? String(index + 1), - text: item.text, - answer: item.answer, - score: item.score, - })) - : [] - if (q.type === "single_choice" || q.type === "multiple_choice") { - const options = (q.content.options ?? []).map((opt, idx) => ({ - id: opt.id ?? String.fromCharCode(65 + idx), - text: opt.text, - isCorrect: opt.isCorrect ?? false, - })) - if (options.length > 0 && subQuestions.length > 0) return { ...base, options, subQuestions } - if (options.length > 0) return { ...base, options } - if (subQuestions.length > 0) return { ...base, subQuestions } - return base - } - if (subQuestions.length > 0) return { ...base, subQuestions } - return base -} - -type AiPreviewQuestion = { - id: string - type: z.infer["type"] - difficulty: number - score: number - content: ReturnType -} - -export type AiPreviewData = { - title: string - rawOutput?: string - sections?: Array<{ - id: string - title: string - questions: AiPreviewQuestion[] - }> - questions?: AiPreviewQuestion[] -} - -export type AiRewriteQuestionData = { - type: z.infer["type"] - difficulty: number - score: number - content: ReturnType -} - -export type AiGeneratedQuestion = { - id: string - type: z.infer["type"] - difficulty: number - score: number - content: ReturnType -} - -export type AiGeneratedStructureNode = { - id: string - type: "group" | "question" - title?: string - questionId?: string - score?: number - children?: AiGeneratedStructureNode[] -} - -export const AiGeneratedStructureNodeSchema: z.ZodType = z.lazy(() => z.object({ - id: z.string().min(1), - type: z.enum(["group", "question"]), - title: z.string().optional(), - questionId: z.string().optional(), - score: z.coerce.number().int().min(0).optional(), - children: z.array(AiGeneratedStructureNodeSchema).optional(), -})) - -export const AiGeneratedStructureSchema = z.array(AiGeneratedStructureNodeSchema) - -const buildPreviewPayload = ( - aiParsed: z.infer, - input: { - title: string - difficulty: number - totalScore: number - questionCount?: number - } -): AiPreviewData => { - const hasSections = Array.isArray(aiParsed.sections) && aiParsed.sections.length > 0 - const baseQuestions = hasSections ? (aiParsed.sections ?? []).flatMap((s) => s.questions) : aiParsed.questions ?? [] - const limit = input.questionCount - let sections = aiParsed.sections - let flatQuestions = baseQuestions - - if (typeof limit === "number" && limit > 0) { - if (hasSections) { - const parsedSections = aiParsed.sections - let remaining = limit - sections = (parsedSections ?? []).map((s) => { - if (remaining <= 0) return { ...s, questions: [] } - const sliced = s.questions.slice(0, remaining) - remaining -= sliced.length - return { ...s, questions: sliced } - }).filter((s) => s.questions.length > 0) - flatQuestions = sections.flatMap((s) => s.questions) - } else { - flatQuestions = baseQuestions.slice(0, limit) - } - } - - const scores = normalizeScores( - flatQuestions.map((q) => q.score ?? 0), - input.totalScore - ) - - let scoreIndex = 0 - const toPreviewQuestion = (q: z.infer): AiPreviewQuestion => ({ - id: createId(), - type: q.type, - difficulty: q.difficulty ?? input.difficulty, - score: scores[scoreIndex++] ?? 0, - content: buildQuestionContent(q), - }) - - if (hasSections && sections && sections.length > 0) { - return { - title: aiParsed.title ?? input.title, - sections: sections.map((section) => ({ - id: createId(), - title: section.title, - questions: section.questions.map((q) => toPreviewQuestion(q)), - })), - } - } - - return { - title: aiParsed.title ?? input.title, - questions: flatQuestions.map((q) => toPreviewQuestion(q)), - } -} - -const previewToDraft = (preview: AiPreviewData): { - generated: AiGeneratedQuestion[] - structure: AiGeneratedStructureNode[] -} => { - const generated: AiGeneratedQuestion[] = [] - const structure: AiGeneratedStructureNode[] = [] - if (Array.isArray(preview.sections) && preview.sections.length > 0) { - for (const section of preview.sections) { - const children: AiGeneratedStructureNode[] = [] - for (const question of section.questions) { - generated.push({ - id: question.id, - type: question.type, - difficulty: question.difficulty, - score: question.score, - content: question.content, - }) - children.push({ - id: createId(), - type: "question", - questionId: question.id, - score: question.score, - }) - } - structure.push({ - id: section.id || createId(), - type: "group", - title: section.title, - children, - }) - } - return { generated, structure } - } - for (const question of preview.questions ?? []) { - generated.push({ - id: question.id, - type: question.type, - difficulty: question.difficulty, - score: question.score, - content: question.content, - }) - structure.push({ - id: createId(), - type: "question", - questionId: question.id, - score: question.score, - }) - } - return { generated, structure } -} - -export async function generateAiPreviewData(input: { - title: string - subject?: string - grade?: string - difficulty: number - totalScore: number - durationMin: number - questionCount?: number - sourceText: string - aiProviderId?: string -}) { - const sourceValidation = await validateExamSourceText({ - sourceText: input.sourceText, - aiProviderId: input.aiProviderId, - }) - if (!sourceValidation.ok) { - return { ok: false as const, message: sourceValidation.message } - } - const structureDraft = await requestAiExamStructureDraft(input) - if (!structureDraft.ok) return structureDraft - const splitItems = splitStructureItems(structureDraft.data) - const limitedItems = typeof input.questionCount === "number" && input.questionCount > 0 - ? splitItems.slice(0, input.questionCount) - : splitItems - if (limitedItems.length === 0) { - return { ok: false as const, message: "AI returned no questions" } - } - const detailedQuestions = await mapWithConcurrency(limitedItems, 6, (item) => parseQuestionDetail({ - item, - subject: input.subject, - grade: input.grade, - difficulty: input.difficulty, - aiProviderId: input.aiProviderId, - })) - const hasSectionStructure = limitedItems.some((item) => item.sectionIndex !== null) - const aiParsed: z.infer = hasSectionStructure - ? { - title: structureDraft.data.title ?? input.title, - sections: (() => { - const sectionMap = new Map[] }>() - limitedItems.forEach((item, index) => { - if (item.sectionIndex === null) return - const existed = sectionMap.get(item.sectionIndex) - const question = detailedQuestions[index] - if (existed) { - existed.questions.push(question) - return - } - sectionMap.set(item.sectionIndex, { - title: item.sectionTitle || `Section ${item.sectionIndex + 1}`, - questions: [question], - }) - }) - return Array.from(sectionMap.entries()) - .sort((a, b) => a[0] - b[0]) - .map(([, section]) => section) - })(), - questions: undefined, - } - : { - title: structureDraft.data.title ?? input.title, - questions: detailedQuestions, - sections: undefined, - } - const payload = buildPreviewPayload(aiParsed, input) - return { - ok: true as const, - data: payload, - rawOutput: structureDraft.rawOutput, - } -} - -export async function generateAiCreateDraftFromSource(input: { - title: string - subject?: string - grade?: string - difficulty: number - totalScore: number - durationMin: number - questionCount?: number - sourceText: string - aiProviderId?: string -}) { - const preview = await generateAiPreviewData(input) - if (!preview.ok) { - return preview - } - const draft = previewToDraft(preview.data) - return { - ok: true as const, - generated: draft.generated, - structure: draft.structure, - rawOutput: preview.rawOutput, - } -} - -export async function regenerateAiQuestionByInstruction(input: { - instruction: string - originalQuestion: z.infer - sourceText?: string - aiProviderId?: string -}) { - const originalDifficulty = input.originalQuestion.difficulty ?? 3 - const originalScore = input.originalQuestion.score ?? 0 - const contextLines = [ - `Instruction:\n${input.instruction}`, - `Original Question JSON:\n${JSON.stringify(input.originalQuestion, null, 2)}`, - input.sourceText ? `Source Exam Text:\n${input.sourceText}` : "", - ] - const userContent = contextLines.filter((line) => line.length > 0).join("\n\n") - try { - const aiResult = await createAiChatCompletion({ - model: String(env.AI_MODEL ?? "gpt-4o-mini"), - providerId: input.aiProviderId && input.aiProviderId.length > 0 ? input.aiProviderId : undefined, - messages: [ - { role: "system" as const, content: AI_REWRITE_QUESTION_SYSTEM_PROMPT }, - { role: "user" as const, content: userContent }, - ], - temperature: 0.7, - maxTokens: 2000, - }) - const parsed = await parseAiResponse(aiResult.content, input.aiProviderId) - const candidate = parsed && typeof parsed === "object" && "question" in parsed - ? (parsed as { question: unknown }).question - : parsed - const validated = AiQuestionSchema.safeParse(candidate) - if (!validated.success) { - return { ok: false as const, message: "AI question format invalid" } - } - const question = validated.data - return { - ok: true as const, - data: { - type: question.type, - difficulty: question.difficulty ?? originalDifficulty, - score: question.score ?? originalScore, - content: buildQuestionContent(question), - } satisfies AiRewriteQuestionData, - } - } catch (error) { - return { ok: false as const, message: getAiErrorMessage(error) } - } -} - -export async function generateAiExamDraft(input: { - title?: string - subject?: string - grade?: string - difficulty?: number - totalScore?: number - durationMin?: number - questionCount?: number - sourceText: string - aiProviderId?: string -}) { - return requestAiExamDraft(input) -} diff --git a/src/modules/exams/ai-pipeline/index.ts b/src/modules/exams/ai-pipeline/index.ts new file mode 100644 index 0000000..504c5d6 --- /dev/null +++ b/src/modules/exams/ai-pipeline/index.ts @@ -0,0 +1,172 @@ +/** + * AI 试卷生成管线(入口模块) + * + * 本目录由三个子模块组成: + * - parse.ts: Zod schema、JSON 解析、纯转换函数、提示词 + * - request.ts: AI 请求构造与发送 + * - structure.ts: 结构生成与预览/草稿转换 + * + * 本文件负责: + * - 重新导出公共 API(schema、类型、函数) + * - 编排高层流程(generateAiPreviewData / generateAiCreateDraftFromSource) + */ + +import { z } from "zod" + +import { + AiExamResponseSchema, + AiQuestionSchema, +} from "./parse" +import { + parseQuestionDetail, + requestAiExamDraft, + requestAiExamStructureDraft, + validateExamSourceText, + type SplitQuestionItem, +} from "./request" +import { + buildPreviewPayload, + mapWithConcurrency, + previewToDraft, + splitStructureItems, +} from "./structure" + +// --------------------------------------------------------------------------- +// Re-export public schemas & types +// --------------------------------------------------------------------------- + +export { + AiGeneratedStructureSchema, + AiGeneratedStructureNodeSchema, + AiInsertQuestionSchema, + AiQuestionSchema, +} from "./parse" + +export type { + AiGeneratedQuestion, + AiGeneratedStructureNode, + AiPreviewData, + AiPreviewQuestion, + AiRewriteQuestionData, + QuestionContentResult, +} from "./parse" + +// --------------------------------------------------------------------------- +// Re-export public functions +// --------------------------------------------------------------------------- + +export { regenerateAiQuestionByInstruction } from "./request" + +// --------------------------------------------------------------------------- +// High-level orchestration +// --------------------------------------------------------------------------- + +export async function generateAiPreviewData(input: { + title: string + subject?: string + grade?: string + difficulty: number + totalScore: number + durationMin: number + questionCount?: number + sourceText: string + aiProviderId?: string +}) { + const sourceValidation = await validateExamSourceText({ + sourceText: input.sourceText, + aiProviderId: input.aiProviderId, + }) + if (!sourceValidation.ok) { + return { ok: false as const, message: sourceValidation.message } + } + const structureDraft = await requestAiExamStructureDraft(input) + if (!structureDraft.ok) return structureDraft + const splitItems = splitStructureItems(structureDraft.data) + const limitedItems = typeof input.questionCount === "number" && input.questionCount > 0 + ? splitItems.slice(0, input.questionCount) + : splitItems + if (limitedItems.length === 0) { + return { ok: false as const, message: "AI returned no questions" } + } + const detailedQuestions = await mapWithConcurrency(limitedItems, 6, (item) => parseQuestionDetail({ + item, + subject: input.subject, + grade: input.grade, + difficulty: input.difficulty, + aiProviderId: input.aiProviderId, + })) + const hasSectionStructure = limitedItems.some((item: SplitQuestionItem) => item.sectionIndex !== null) + const aiParsed: z.infer = hasSectionStructure + ? { + title: structureDraft.data.title ?? input.title, + sections: (() => { + const sectionMap = new Map[] }>() + limitedItems.forEach((item, index) => { + if (item.sectionIndex === null) return + const existed = sectionMap.get(item.sectionIndex) + const question = detailedQuestions[index] + if (existed) { + existed.questions.push(question) + return + } + sectionMap.set(item.sectionIndex, { + title: item.sectionTitle || `Section ${item.sectionIndex + 1}`, + questions: [question], + }) + }) + return Array.from(sectionMap.entries()) + .sort((a, b) => a[0] - b[0]) + .map(([, section]) => section) + })(), + questions: undefined, + } + : { + title: structureDraft.data.title ?? input.title, + questions: detailedQuestions, + sections: undefined, + } + const payload = buildPreviewPayload(aiParsed, input) + return { + ok: true as const, + data: payload, + rawOutput: structureDraft.rawOutput, + } +} + +export async function generateAiCreateDraftFromSource(input: { + title: string + subject?: string + grade?: string + difficulty: number + totalScore: number + durationMin: number + questionCount?: number + sourceText: string + aiProviderId?: string +}) { + const preview = await generateAiPreviewData(input) + if (!preview.ok) { + return preview + } + const draft = previewToDraft(preview.data) + return { + ok: true as const, + generated: draft.generated, + structure: draft.structure, + rawOutput: preview.rawOutput, + } +} + +export async function generateAiExamDraft(input: { + title?: string + subject?: string + grade?: string + difficulty?: number + totalScore?: number + durationMin?: number + questionCount?: number + sourceText: string + aiProviderId?: string +}) { + return requestAiExamDraft(input) +} diff --git a/src/modules/exams/ai-pipeline/parse.ts b/src/modules/exams/ai-pipeline/parse.ts new file mode 100644 index 0000000..eb0c0a1 --- /dev/null +++ b/src/modules/exams/ai-pipeline/parse.ts @@ -0,0 +1,426 @@ +/** + * AI 响应解析与 Zod 校验 + * + * 职责: + * - JSON 提取与修复(从 AI 返回的原始文本中提取合法 JSON) + * - Zod schema 定义(题目、试卷、结构等) + * - 共享类型导出 + */ + +import { z } from "zod" +import { createAiChatCompletion } from "@/shared/lib/ai" +import { env } from "@/env.mjs" + +// --------------------------------------------------------------------------- +// Zod schemas +// --------------------------------------------------------------------------- + +const AiSubQuestionSchema = z.object({ + id: z.string().min(1).optional(), + text: z.string().min(1), + answer: z.string().min(1).optional(), + score: z.coerce.number().int().min(0).optional(), +}) + +const AiQuestionContentSchema = z.object({ + text: z.string().min(1), + options: z + .array( + z.object({ + id: z.string().min(1).optional(), + text: z.string().min(1), + isCorrect: z.boolean().optional(), + }) + ) + .optional(), + subQuestions: z.array(AiSubQuestionSchema).optional(), +}) + +export const AiQuestionSchema = z.object({ + type: z.enum(["single_choice", "multiple_choice", "text", "judgment"]), + difficulty: z.coerce.number().int().min(1).max(5).optional(), + score: z.coerce.number().int().min(0).optional(), + content: AiQuestionContentSchema, +}) + +export const AiInsertQuestionSchema = z.object({ + id: z.string().min(1), + type: z.enum(["single_choice", "multiple_choice", "text", "judgment"]), + difficulty: z.coerce.number().int().min(1).max(5), + score: z.coerce.number().int().min(0), + content: AiQuestionContentSchema.extend({ + options: z + .array( + z.object({ + id: z.string().min(1), + text: z.string().min(1), + isCorrect: z.boolean().optional(), + }) + ) + .optional(), + subQuestions: z.array( + AiSubQuestionSchema.extend({ + id: z.string().min(1), + }) + ).optional(), + }), +}) + +const AiSectionSchema = z.object({ + title: z.string().min(1), + questions: z.array(AiQuestionSchema).min(1), +}) + +export const AiExamResponseSchema = z.object({ + title: z.string().optional(), + questions: z.array(AiQuestionSchema).optional(), + sections: z.array(AiSectionSchema).optional(), +}) + +const AiStructureQuestionSchema = z.object({ + text: z.string().min(1), + score: z.coerce.number().int().min(0).optional(), +}) + +const AiStructureSectionSchema = z.object({ + title: z.string().min(1), + questions: z.array(AiStructureQuestionSchema).min(1), +}) + +export const AiStructureResponseSchema = z.object({ + title: z.string().optional(), + sections: z.array(AiStructureSectionSchema).optional(), + questions: z.array(AiStructureQuestionSchema).optional(), +}) + +const AiSourceValidationSchema = z.object({ + valid: z.boolean(), + reason: z.string().optional(), +}) + +// --------------------------------------------------------------------------- +// Structure node schema (recursive) +// --------------------------------------------------------------------------- + +export type AiGeneratedStructureNode = { + id: string + type: "group" | "question" + title?: string + questionId?: string + score?: number + children?: AiGeneratedStructureNode[] +} + +export const AiGeneratedStructureNodeSchema: z.ZodType = z.lazy(() => z.object({ + id: z.string().min(1), + type: z.enum(["group", "question"]), + title: z.string().optional(), + questionId: z.string().optional(), + score: z.coerce.number().int().min(0).optional(), + children: z.array(AiGeneratedStructureNodeSchema).optional(), +})) + +export const AiGeneratedStructureSchema = z.array(AiGeneratedStructureNodeSchema) + +// --------------------------------------------------------------------------- +// JSON extraction & repair +// --------------------------------------------------------------------------- + +const sanitizeJsonCandidate = (value: string): string => value + .replace(/\[\s*\.\.\.\s*\]/g, "[]") + .replace(/\{\s*\.\.\.\s*\}/g, "{}") + .trim() + +const tryParseJson = (value: string): unknown | null => { + const sanitized = sanitizeJsonCandidate(value) + if (!sanitized) return null + try { + return JSON.parse(sanitized) + } catch { + return null + } +} + +const extractBalancedJsonSegment = (value: string): string | null => { + const startBrace = value.indexOf("{") + const startBracket = value.indexOf("[") + const start = + startBrace === -1 + ? startBracket + : startBracket === -1 + ? startBrace + : Math.min(startBrace, startBracket) + if (start === -1) return null + const opening = value[start] + const closing = opening === "{" ? "}" : "]" + let depth = 0 + let inString = false + let escaped = false + for (let i = start; i < value.length; i += 1) { + const char = value[i] + if (inString) { + if (escaped) { + escaped = false + } else if (char === "\\") { + escaped = true + } else if (char === "\"") { + inString = false + } + continue + } + if (char === "\"") { + inString = true + continue + } + if (char === opening) { + depth += 1 + continue + } + if (char === closing) { + depth -= 1 + if (depth === 0) { + return value.slice(start, i + 1) + } + } + } + return null +} + +const extractJson = (raw: string): unknown => { + const trimmed = raw.trim() + const candidates: string[] = [] + const fencedMatches = [...trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/ig)] + if (fencedMatches.length > 0) { + candidates.push(...fencedMatches.map((match) => (match[1] ?? "").trim())) + } + candidates.push(trimmed) + for (const candidate of candidates) { + const direct = tryParseJson(candidate) + if (direct !== null) return direct + const segment = extractBalancedJsonSegment(candidate) + if (!segment) continue + const parsed = tryParseJson(segment) + if (parsed !== null) return parsed + } + throw new Error("Invalid AI response") +} + +const AI_JSON_REPAIR_PROMPT = [ + "You are a JSON repair engine.", + "Fix the provided invalid JSON into valid JSON only.", + "Keep the original structure and values as much as possible.", + "Do not use placeholders such as ... or [...].", + "Return JSON only without markdown.", +].join("\n") + +const repairJson = async (raw: string, providerId?: string) => { + const aiResult = await createAiChatCompletion({ + model: String(env.AI_MODEL ?? "gpt-4o-mini"), + providerId, + messages: [ + { role: "system" as const, content: AI_JSON_REPAIR_PROMPT }, + { role: "user" as const, content: raw }, + ], + temperature: 0, + maxTokens: 4000, + }) + return extractJson(aiResult.content) +} + +export const parseAiResponse = async (raw: string, providerId?: string) => { + try { + return extractJson(raw) + } catch { + return repairJson(raw, providerId) + } +} + +// --------------------------------------------------------------------------- +// Shared types +// --------------------------------------------------------------------------- + +export type QuestionContentResult = { + text: string + options?: Array<{ id: string; text: string; isCorrect: boolean }> + subQuestions?: Array<{ id: string; text: string; answer?: string; score?: number }> +} + +export type AiPreviewQuestion = { + id: string + type: z.infer["type"] + difficulty: number + score: number + content: QuestionContentResult +} + +export type AiPreviewData = { + title: string + rawOutput?: string + sections?: Array<{ + id: string + title: string + questions: AiPreviewQuestion[] + }> + questions?: AiPreviewQuestion[] +} + +export type AiRewriteQuestionData = { + type: z.infer["type"] + difficulty: number + score: number + content: QuestionContentResult +} + +export type AiGeneratedQuestion = { + id: string + type: z.infer["type"] + difficulty: number + score: number + content: QuestionContentResult +} + +// --------------------------------------------------------------------------- +// Pure transformation functions +// --------------------------------------------------------------------------- + +export const normalizeScores = (scores: number[], totalScore: number): number[] => { + if (scores.length === 0) return [] + const sum = scores.reduce((acc, s) => acc + s, 0) + if (sum <= 0) { + const base = Math.floor(totalScore / scores.length) + const remainder = totalScore - base * scores.length + return scores.map((_, idx) => base + (idx < remainder ? 1 : 0)) + } + const scaled = scores.map((s) => Math.max(0, Math.round((s / sum) * totalScore))) + let diff = totalScore - scaled.reduce((acc, s) => acc + s, 0) + let i = 0 + while (diff !== 0 && i < scaled.length * 2) { + const idx = i % scaled.length + if (diff > 0) { + scaled[idx] += 1 + diff -= 1 + } else if (scaled[idx] > 0) { + scaled[idx] -= 1 + diff += 1 + } + i += 1 + } + return scaled +} + +export const buildQuestionContent = (q: z.infer): QuestionContentResult => { + const base = { text: q.content.text } + const subQuestions = Array.isArray(q.content.subQuestions) + ? q.content.subQuestions.map((item, index) => ({ + id: item.id ?? String(index + 1), + text: item.text, + answer: item.answer, + score: item.score, + })) + : [] + if (q.type === "single_choice" || q.type === "multiple_choice") { + const options = (q.content.options ?? []).map((opt, idx) => ({ + id: opt.id ?? String.fromCharCode(65 + idx), + text: opt.text, + isCorrect: opt.isCorrect ?? false, + })) + if (options.length > 0 && subQuestions.length > 0) return { ...base, options, subQuestions } + if (options.length > 0) return { ...base, options } + if (subQuestions.length > 0) return { ...base, subQuestions } + return base + } + if (subQuestions.length > 0) return { ...base, subQuestions } + return base +} + +// --------------------------------------------------------------------------- +// Prompts +// --------------------------------------------------------------------------- + +export const AI_EXAM_SYSTEM_PROMPT = [ + "You are an exam parsing engine.", + "Parse the provided exam text and output JSON only.", + "Allowed question types: single_choice, multiple_choice, judgment, text.", + "Preserve the original order and sectioning if present.", + "Escape double quotes inside string values.", + "Output schema:", + "{", + ' "sections": [', + ' { "title": "Section Title", "questions": [', + ' { "type": "single_choice", "difficulty": 1, "score": 5, "content": { "text": "...", "options": [ { "id": "A", "text": "...", "isCorrect": true } ] } }', + " ] }", + " ]", + "}", + "For grouped blanks or one prompt with multiple small questions, keep one parent question and place each child item into content.subQuestions.", + 'content.subQuestions item schema: { "id": "1", "text": "lǎn duò( )", "answer": "懒惰", "score": 1 }', + "If you do not need sections, return { \"questions\": [] } or include real question items.", + "Never output placeholders like ..., [...], or {...}.", + "Return JSON only without markdown.", +].join("\n") + +export const AI_REWRITE_QUESTION_SYSTEM_PROMPT = [ + "You are a question rewriting engine.", + "Rewrite exactly one question based on teacher instruction.", + "Return JSON only without markdown.", + "Allowed question types: single_choice, multiple_choice, judgment, text.", + "Output schema:", + "{", + ' "type": "single_choice | multiple_choice | judgment | text",', + ' "difficulty": 1,', + ' "score": 5,', + ' "content": { "text": "...", "options": [ { "id": "A", "text": "...", "isCorrect": true } ], "subQuestions": [ { "id": "1", "text": "...", "answer": "...", "score": 1 } ] }', + "}", + "For judgment/text, options can be omitted. Keep subQuestions when original question has multiple child items.", + "Never output placeholders like ..., [...], or {...}.", +].join("\n") + +export const AI_EXAM_STRUCTURE_SYSTEM_PROMPT = [ + "You are an exam splitter engine.", + "Split the provided exam text into ordered question units quickly.", + "Do not deeply analyze choices or answers in this step.", + "Keep original sectioning and question order.", + "If one stem contains multiple numbered sub-items, keep them in one question unit and include all sub-items in the same text.", + "Do not split one parent question into several child-only units.", + "Output JSON only.", + "Output schema:", + "{", + ' "title": "Optional title",', + ' "sections": [', + ' { "title": "Section Title", "questions": [', + ' { "text": "Original full question text", "score": 5 }', + " ] }", + " ]", + "}", + "If no sections, return:", + '{ "questions": [ { "text": "Original full question text", "score": 5 } ] }', + "Never output placeholders like ..., [...], or {...}.", +].join("\n") + +export const AI_EXAM_SOURCE_VALIDATION_SYSTEM_PROMPT = [ + "You are an exam text validator.", + "Judge whether the input text is readable and likely a normal exam/question text.", + "Reject garbled text, random symbols, severely disordered fragments, or meaningless content.", + "Do not require strict section formatting. Focus only on readability and whether it resembles exam questions.", + "Return JSON only without markdown.", + "Output schema:", + '{ "valid": true, "reason": "short reason" }', +].join("\n") + +export const AI_QUESTION_DETAIL_SYSTEM_PROMPT = [ + "You are an exam question detail parser.", + "Given one split question text, output one structured question JSON only.", + "Allowed question types: single_choice, multiple_choice, judgment, text.", + "For one stem with multiple child sub-items, keep one parent content.text and place child items in content.subQuestions.", + "Use exact key name content.subQuestions (camelCase).", + "Output schema:", + "{", + ' "type": "single_choice | multiple_choice | judgment | text",', + ' "difficulty": 1,', + ' "score": 5,', + ' "content": { "text": "...", "options": [ { "id": "A", "text": "...", "isCorrect": true } ], "subQuestions": [ { "id": "1", "text": "...", "answer": "...", "score": 1 } ] }', + "}", + "For judgment/text, options can be omitted.", + "Never output placeholders like ..., [...], or {...}.", +].join("\n") + +export { AiSourceValidationSchema } diff --git a/src/modules/exams/ai-pipeline/request.ts b/src/modules/exams/ai-pipeline/request.ts new file mode 100644 index 0000000..af31322 --- /dev/null +++ b/src/modules/exams/ai-pipeline/request.ts @@ -0,0 +1,305 @@ +/** + * AI 请求构造 + * + * 职责: + * - 构造 AI 聊天消息 + * - 发送 AI 请求(试卷解析、结构拆分、题目详情、源文本校验、题目重写) + * - 依赖 parse.ts 中的 schema、parseAiResponse 与 buildQuestionContent + */ + +import { z } from "zod" +import { createAiChatCompletion, getAiErrorMessage } from "@/shared/lib/ai" +import { env } from "@/env.mjs" + +import { + AI_EXAM_SOURCE_VALIDATION_SYSTEM_PROMPT, + AI_EXAM_STRUCTURE_SYSTEM_PROMPT, + AI_EXAM_SYSTEM_PROMPT, + AI_QUESTION_DETAIL_SYSTEM_PROMPT, + AI_REWRITE_QUESTION_SYSTEM_PROMPT, + AiExamResponseSchema, + AiQuestionSchema, + AiSourceValidationSchema, + AiStructureResponseSchema, + buildQuestionContent, + parseAiResponse, +} from "./parse" + +// --------------------------------------------------------------------------- +// Shared types +// --------------------------------------------------------------------------- + +type AiChatMessage = { role: "system" | "user"; content: string } + +type AiDraftResult = + | { ok: true; data: z.infer; rawOutput: string } + | { ok: false; message: string } + +type AiStructureDraftResult = + | { ok: true; data: z.infer; rawOutput: string } + | { ok: false; message: string } + +export type SplitQuestionItem = { + sectionIndex: number | null + sectionTitle?: string + text: string + score?: number +} + +// --------------------------------------------------------------------------- +// Message builders +// --------------------------------------------------------------------------- + +const buildAiMessages = (input: { + title?: string + subject?: string + grade?: string + difficulty?: number + totalScore?: number + durationMin?: number + questionCount?: number + sourceText: string +}): AiChatMessage[] => { + const userLines = [ + input.title ? `Title: ${input.title}` : "", + input.subject ? `Subject: ${input.subject}` : "", + input.grade ? `Grade: ${input.grade}` : "", + typeof input.difficulty === "number" ? `Difficulty: ${input.difficulty}` : "", + typeof input.totalScore === "number" ? `Total Score: ${input.totalScore}` : "", + typeof input.durationMin === "number" ? `Duration (min): ${input.durationMin}` : "", + input.questionCount ? `Question Count: ${input.questionCount}` : "", + `Source Exam Text:\n${input.sourceText}`, + ] + const userContent = userLines.filter((l) => l.length > 0).join("\n") + return [ + { role: "system" as const, content: AI_EXAM_SYSTEM_PROMPT }, + { role: "user" as const, content: userContent }, + ] +} + +// --------------------------------------------------------------------------- +// AI request functions +// --------------------------------------------------------------------------- + +export const requestAiExamDraft = async (input: { + title?: string + subject?: string + grade?: string + difficulty?: number + totalScore?: number + durationMin?: number + questionCount?: number + sourceText: string + aiProviderId?: string +}): Promise => { + try { + const aiResult = await createAiChatCompletion({ + model: String(env.AI_MODEL ?? "gpt-4o-mini"), + providerId: input.aiProviderId, + messages: buildAiMessages(input), + temperature: 0.7, + maxTokens: 4000, + }) + const rawOutput = aiResult.content + const data = await parseAiResponse(rawOutput, input.aiProviderId) + const validated = AiExamResponseSchema.safeParse(data) + if (!validated.success) { + return { ok: false, message: "AI response format invalid" } + } + return { ok: true, data: validated.data, rawOutput } + } catch (error) { + return { ok: false, message: getAiErrorMessage(error) } + } +} + +export const requestAiExamStructureDraft = async (input: { + title?: string + subject?: string + grade?: string + difficulty?: number + totalScore?: number + durationMin?: number + questionCount?: number + sourceText: string + aiProviderId?: string +}): Promise => { + try { + const aiResult = await createAiChatCompletion({ + model: String(env.AI_MODEL ?? "gpt-4o-mini"), + providerId: input.aiProviderId, + messages: [ + { role: "system" as const, content: AI_EXAM_STRUCTURE_SYSTEM_PROMPT }, + { role: "user" as const, content: buildAiMessages(input)[1].content }, + ], + temperature: 0.2, + maxTokens: 4000, + }) + const rawOutput = aiResult.content + const data = await parseAiResponse(rawOutput, input.aiProviderId) + const validated = AiStructureResponseSchema.safeParse(data) + if (!validated.success) { + return { ok: false, message: "AI response format invalid" } + } + return { ok: true, data: validated.data, rawOutput } + } catch (error) { + return { ok: false, message: getAiErrorMessage(error) } + } +} + +export const validateExamSourceText = async (input: { sourceText: string; aiProviderId?: string }) => { + const text = input.sourceText.trim() + if (!text) { + return { ok: false as const, message: "请先粘贴试卷文本" } + } + const userContent = [ + "请判断下面文本是否是可读、正常的题目/试卷内容(不是乱码、随机字符或混乱文本)。", + `文本内容:\n${text}`, + ].join("\n\n") + try { + const aiResult = await createAiChatCompletion({ + model: String(env.AI_MODEL ?? "gpt-4o-mini"), + providerId: input.aiProviderId, + messages: [ + { role: "system" as const, content: AI_EXAM_SOURCE_VALIDATION_SYSTEM_PROMPT }, + { role: "user" as const, content: userContent }, + ], + temperature: 0, + maxTokens: 300, + }) + const parsed = await parseAiResponse(aiResult.content, input.aiProviderId) + const validated = AiSourceValidationSchema.safeParse(parsed) + if (!validated.success) { + return { ok: false as const, message: "试卷文本校验失败,请重试" } + } + if (!validated.data.valid) { + return { + ok: false as const, + message: validated.data.reason?.trim() || "识别为乱码或混乱文本,请粘贴清晰完整的题目内容", + } + } + return { ok: true as const } + } catch (error) { + return { ok: false as const, message: getAiErrorMessage(error) } + } +} + +export const parseQuestionDetail = async (input: { + item: SplitQuestionItem + subject?: string + grade?: string + difficulty: number + aiProviderId?: string +}): Promise> => { + const normalizeQuestionCandidate = (value: unknown): unknown => { + if (!value || typeof value !== "object") return value + // 从 unknown 收窄为 Record 以进行字段检查 + const record = value as Record + const contentRaw = record.content + if (!contentRaw || typeof contentRaw !== "object") return value + const content = contentRaw as Record + const normalizedSubQuestions = Array.isArray(content.subQuestions) + ? content.subQuestions + : Array.isArray(content.subquestions) + ? content.subquestions + : Array.isArray(content.sub_questions) + ? content.sub_questions + : undefined + if (!normalizedSubQuestions) return value + return { + ...record, + content: { + ...content, + subQuestions: normalizedSubQuestions, + }, + } + } + + const userContent = [ + input.subject ? `Subject: ${input.subject}` : "", + input.grade ? `Grade: ${input.grade}` : "", + `Question Text:\n${input.item.text}`, + ].filter((line) => line.length > 0).join("\n\n") + + try { + const aiResult = await createAiChatCompletion({ + model: String(env.AI_MODEL ?? "gpt-4o-mini"), + providerId: input.aiProviderId, + messages: [ + { role: "system" as const, content: AI_QUESTION_DETAIL_SYSTEM_PROMPT }, + { role: "user" as const, content: userContent }, + ], + temperature: 0.4, + maxTokens: 1200, + }) + const parsed = await parseAiResponse(aiResult.content, input.aiProviderId) + const candidate = parsed && typeof parsed === "object" && "question" in parsed + ? (parsed as { question: unknown }).question + : parsed + const validated = AiQuestionSchema.safeParse(normalizeQuestionCandidate(candidate)) + if (validated.success) { + const q = validated.data + return { + type: q.type, + difficulty: q.difficulty ?? input.difficulty, + score: q.score ?? input.item.score ?? 0, + content: q.content, + } satisfies z.infer + } + } catch { + } + + return { + type: "text", + difficulty: input.difficulty, + score: input.item.score ?? 0, + content: { text: input.item.text }, + } satisfies z.infer +} + +export const regenerateAiQuestionByInstruction = async (input: { + instruction: string + originalQuestion: z.infer + sourceText?: string + aiProviderId?: string +}) => { + const originalDifficulty = input.originalQuestion.difficulty ?? 3 + const originalScore = input.originalQuestion.score ?? 0 + const contextLines = [ + `Instruction:\n${input.instruction}`, + `Original Question JSON:\n${JSON.stringify(input.originalQuestion, null, 2)}`, + input.sourceText ? `Source Exam Text:\n${input.sourceText}` : "", + ] + const userContent = contextLines.filter((line) => line.length > 0).join("\n\n") + try { + const aiResult = await createAiChatCompletion({ + model: String(env.AI_MODEL ?? "gpt-4o-mini"), + providerId: input.aiProviderId && input.aiProviderId.length > 0 ? input.aiProviderId : undefined, + messages: [ + { role: "system" as const, content: AI_REWRITE_QUESTION_SYSTEM_PROMPT }, + { role: "user" as const, content: userContent }, + ], + temperature: 0.7, + maxTokens: 2000, + }) + const parsed = await parseAiResponse(aiResult.content, input.aiProviderId) + const candidate = parsed && typeof parsed === "object" && "question" in parsed + ? (parsed as { question: unknown }).question + : parsed + const validated = AiQuestionSchema.safeParse(candidate) + if (!validated.success) { + return { ok: false as const, message: "AI question format invalid" } + } + const question = validated.data + return { + ok: true as const, + data: { + type: question.type, + difficulty: question.difficulty ?? originalDifficulty, + score: question.score ?? originalScore, + content: buildQuestionContent(question), + }, + } + } catch (error) { + return { ok: false as const, message: getAiErrorMessage(error) } + } +} diff --git a/src/modules/exams/ai-pipeline/structure.ts b/src/modules/exams/ai-pipeline/structure.ts new file mode 100644 index 0000000..2c1ac37 --- /dev/null +++ b/src/modules/exams/ai-pipeline/structure.ts @@ -0,0 +1,203 @@ +/** + * 结构生成 + * + * 职责: + * - 将 AI 解析结果转换为预览数据(buildPreviewPayload) + * - 将预览数据转换为持久化草稿(previewToDraft) + * - 拆分结构条目(splitStructureItems) + * - 并发映射工具(mapWithConcurrency) + * - 依赖 parse.ts 中的类型与纯函数 + */ + +import { createId } from "@paralleldrive/cuid2" +import { z } from "zod" + +import { + AiExamResponseSchema, + AiQuestionSchema, + AiStructureResponseSchema, + buildQuestionContent, + normalizeScores, + type AiGeneratedQuestion, + type AiGeneratedStructureNode, + type AiPreviewData, + type AiPreviewQuestion, +} from "./parse" +import type { SplitQuestionItem } from "./request" + +// --------------------------------------------------------------------------- +// Structure splitting +// --------------------------------------------------------------------------- + +export const splitStructureItems = ( + draft: z.infer +): SplitQuestionItem[] => { + const hasSections = Array.isArray(draft.sections) && draft.sections.length > 0 + if (!hasSections) { + return (draft.questions ?? []).map((q) => ({ + sectionIndex: null, + sectionTitle: undefined, + text: q.text, + score: q.score, + } satisfies SplitQuestionItem)) + } + const rows: SplitQuestionItem[] = [] + const sections = draft.sections + if (sections) { + sections.forEach((section, sectionIndex) => { + section.questions.forEach((q) => { + rows.push({ + sectionIndex, + sectionTitle: section.title, + text: q.text, + score: q.score, + }) + }) + }) + } + return rows +} + +// --------------------------------------------------------------------------- +// Concurrency utility +// --------------------------------------------------------------------------- + +export const mapWithConcurrency = async ( + items: T[], + concurrency: number, + worker: (item: T, index: number) => Promise +): Promise => { + const results = new Array(items.length) + let cursor = 0 + const runWorker = async () => { + while (cursor < items.length) { + const index = cursor + cursor += 1 + results[index] = await worker(items[index], index) + } + } + const workers = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, () => runWorker()) + await Promise.all(workers) + return results +} + +// --------------------------------------------------------------------------- +// Preview payload building +// --------------------------------------------------------------------------- + +export const buildPreviewPayload = ( + aiParsed: z.infer, + input: { + title: string + difficulty: number + totalScore: number + questionCount?: number + } +): AiPreviewData => { + const hasSections = Array.isArray(aiParsed.sections) && aiParsed.sections.length > 0 + const baseQuestions = hasSections ? (aiParsed.sections ?? []).flatMap((s) => s.questions) : aiParsed.questions ?? [] + const limit = input.questionCount + let sections = aiParsed.sections + let flatQuestions = baseQuestions + + if (typeof limit === "number" && limit > 0) { + if (hasSections) { + const parsedSections = aiParsed.sections + let remaining = limit + sections = (parsedSections ?? []).map((s) => { + if (remaining <= 0) return { ...s, questions: [] } + const sliced = s.questions.slice(0, remaining) + remaining -= sliced.length + return { ...s, questions: sliced } + }).filter((s) => s.questions.length > 0) + flatQuestions = sections.flatMap((s) => s.questions) + } else { + flatQuestions = baseQuestions.slice(0, limit) + } + } + + const scores = normalizeScores( + flatQuestions.map((q) => q.score ?? 0), + input.totalScore + ) + + let scoreIndex = 0 + const toPreviewQuestion = (q: z.infer): AiPreviewQuestion => ({ + id: createId(), + type: q.type, + difficulty: q.difficulty ?? input.difficulty, + score: scores[scoreIndex++] ?? 0, + content: buildQuestionContent(q), + }) + + if (hasSections && sections && sections.length > 0) { + return { + title: aiParsed.title ?? input.title, + sections: sections.map((section) => ({ + id: createId(), + title: section.title, + questions: section.questions.map((q) => toPreviewQuestion(q)), + })), + } + } + + return { + title: aiParsed.title ?? input.title, + questions: flatQuestions.map((q) => toPreviewQuestion(q)), + } +} + +// --------------------------------------------------------------------------- +// Preview → Draft conversion +// --------------------------------------------------------------------------- + +export const previewToDraft = (preview: AiPreviewData): { + generated: AiGeneratedQuestion[] + structure: AiGeneratedStructureNode[] +} => { + const generated: AiGeneratedQuestion[] = [] + const structure: AiGeneratedStructureNode[] = [] + if (Array.isArray(preview.sections) && preview.sections.length > 0) { + for (const section of preview.sections) { + const children: AiGeneratedStructureNode[] = [] + for (const question of section.questions) { + generated.push({ + id: question.id, + type: question.type, + difficulty: question.difficulty, + score: question.score, + content: question.content, + }) + children.push({ + id: createId(), + type: "question", + questionId: question.id, + score: question.score, + }) + } + structure.push({ + id: section.id || createId(), + type: "group", + title: section.title, + children, + }) + } + return { generated, structure } + } + for (const question of preview.questions ?? []) { + generated.push({ + id: question.id, + type: question.type, + difficulty: question.difficulty, + score: question.score, + content: question.content, + }) + structure.push({ + id: createId(), + type: "question", + questionId: question.id, + score: question.score, + }) + } + return { generated, structure } +} diff --git a/src/modules/exams/components/exam-actions.tsx b/src/modules/exams/components/exam-actions.tsx index 9ea8c92..ea74497 100644 --- a/src/modules/exams/components/exam-actions.tsx +++ b/src/modules/exams/components/exam-actions.tsx @@ -36,7 +36,27 @@ import { deleteExamAction, duplicateExamAction, updateExamAction, getExamPreview import { Exam } from "../types" import { ExamPaperPreview } from "./assembly/exam-paper-preview" import type { ExamNode } from "./assembly/selected-question-list" -import type { Question } from "@/modules/questions/types" + +// Raw structure node shape returned from the DB before hydration +type RawStructureNode = { + id?: string + type?: string + questionId?: string + score?: number + title?: string + children?: RawStructureNode[] +} + +// Type guard to narrow unknown structure payload to raw nodes +const isRawStructureNode = (v: unknown): v is RawStructureNode => { + if (typeof v !== "object" || v === null) return false + // 从 unknown 收窄为 Record 以进行字段检查 + const obj = v as Record + return typeof obj.type === "string" +} + +const isRawStructureArray = (v: unknown): v is RawStructureNode[] => + Array.isArray(v) && v.every((item) => isRawStructureNode(item)) interface ExamActionsProps { exam: Exam @@ -57,25 +77,39 @@ export function ExamActions({ exam }: ExamActionsProps) { try { const result = await getExamPreviewAction(exam.id) if (result.success && result.data) { - const { structure, questions } = result.data - const questionById = new Map() - for (const q of questions) questionById.set(q.id, q as unknown as Question) + const { structure } = result.data - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const hydrate = (nodes: any[]): ExamNode[] => { - return nodes.map((node) => { - if (node.type === "question") { - const q = node.questionId ? questionById.get(node.questionId) : undefined - return { ...node, question: q } - } - if (node.type === "group") { - return { ...node, children: hydrate(node.children || []) } - } - return node - }) + const hydrate = (nodes: RawStructureNode[]): ExamNode[] => { + return nodes.map((node) => { + if (node.type === "question") { + return { + id: node.id ?? node.questionId ?? "", + type: "question" as const, + questionId: node.questionId, + score: node.score, + // Question content is not available in preview payload; left undefined + } + } + if (node.type === "group") { + return { + id: node.id ?? "", + type: "group" as const, + title: node.title, + score: node.score, + children: hydrate(node.children ?? []), + } + } + // Unknown node type: treat as group with no children to avoid runtime crash + return { + id: node.id ?? "", + type: "group" as const, + title: node.title, + children: [], + } + }) } - - const nodes = Array.isArray(structure) ? hydrate(structure) : [] + + const nodes = isRawStructureArray(structure) ? hydrate(structure) : [] setPreviewNodes(nodes) } else { toast.error(t("exam.actions.previewFailed")) diff --git a/src/modules/exams/components/exam-ai-generator.tsx b/src/modules/exams/components/exam-ai-generator.tsx index cf49a45..29b81db 100644 --- a/src/modules/exams/components/exam-ai-generator.tsx +++ b/src/modules/exams/components/exam-ai-generator.tsx @@ -36,6 +36,7 @@ import { } from "@/shared/components/ui/dialog" import { AiProviderSettingsCard } from "@/modules/settings/components/ai-provider-settings-card" import type { AiProviderSummary } from "@/modules/settings/actions" +import { formatDateTime } from "@/shared/lib/utils" import type { ExamFormValues, PreviewBackgroundTask } from "./exam-form-types" import { aiProviderLabels } from "./exam-form-types" @@ -78,12 +79,7 @@ export function ExamAiGenerator({ runningPreviewTaskCount, queuedPreviewTaskCount, }: ExamAiGeneratorProps) { - const formatTaskTime = (value: number) => new Date(value).toLocaleString("zh-CN", { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - }) + const formatTaskTime = (value: number) => formatDateTime(new Date(value)) return ( diff --git a/src/modules/exams/components/exam-basic-info-form.tsx b/src/modules/exams/components/exam-basic-info-form.tsx index 0e98348..f8249cb 100644 --- a/src/modules/exams/components/exam-basic-info-form.tsx +++ b/src/modules/exams/components/exam-basic-info-form.tsx @@ -1,22 +1,6 @@ "use client" import type { Control } from "react-hook-form" -import { - FormField, - FormItem, - FormLabel, - FormControl, - FormMessage, - FormDescription, -} from "@/shared/components/ui/form" -import { Input } from "@/shared/components/ui/input" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/shared/components/ui/select" import { Card, CardContent, @@ -24,6 +8,8 @@ import { CardHeader, CardTitle, } from "@/shared/components/ui/card" +import { TextField } from "@/shared/components/form-fields/text-field" +import { SelectField } from "@/shared/components/form-fields/select-field" import type { ExamFormValues } from "./exam-form-types" type ExamBasicInfoFormProps = { @@ -34,6 +20,14 @@ type ExamBasicInfoFormProps = { loadingGrades: boolean } +const DIFFICULTY_OPTIONS = [ + { value: "1", label: "Level 1 (Easy)" }, + { value: "2", label: "Level 2" }, + { value: "3", label: "Level 3 (Medium)" }, + { value: "4", label: "Level 4" }, + { value: "5", label: "Level 5 (Hard)" }, +] + export function ExamBasicInfoForm({ control, subjects, @@ -50,139 +44,60 @@ export function ExamBasicInfoForm({ - ( - - Title - - - - - - )} + label="Title" + placeholder="e.g. Midterm Mathematics Exam" />
- ( - - Subject - - - - )} + label="Subject" + placeholder={loadingSubjects ? "Loading subjects..." : "Select subject"} + options={subjects.map((s) => ({ value: s.id, label: s.name }))} + disabled={loadingSubjects} /> - ( - - Grade Level - - - - )} + label="Grade Level" + placeholder={loadingGrades ? "Loading grades..." : "Select grade level"} + options={grades.map((g) => ({ value: g.id, label: g.name }))} + disabled={loadingGrades} />
- ( - - Difficulty - - - - )} + label="Difficulty" + placeholder="Select level" + options={DIFFICULTY_OPTIONS} /> - ( - - Total Score - - - - - - )} + label="Total Score" + type="number" /> - ( - - Duration (min) - - - - - - )} + label="Duration (min)" + type="number" />
- ( - - Schedule Start Time (Optional) - - - - - If set, this exam will be scheduled for a specific time. - - - - )} + label="Schedule Start Time (Optional)" + type="datetime-local" + description="If set, this exam will be scheduled for a specific time." />
diff --git a/src/modules/exams/components/exam-columns.tsx b/src/modules/exams/components/exam-columns.tsx index f509b41..483c548 100644 --- a/src/modules/exams/components/exam-columns.tsx +++ b/src/modules/exams/components/exam-columns.tsx @@ -108,7 +108,11 @@ export function createExamColumns(t: TranslationFn): ColumnDef[] { const diff = row.original.difficulty return (
-
+
{[1, 2, 3, 4, 5].map((level) => (
{ + // 监考模式必须设置考试时长 + if (data.examMode === "proctored" && (data.durationMinutes === null || data.durationMinutes === undefined || data.durationMinutes < 1)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["durationMinutes"], + message: "Duration is required in proctored mode.", + }) + } + // 限时/监考模式必须设置考试时长 + if (data.examMode === "timed" && (data.durationMinutes === null || data.durationMinutes === undefined || data.durationMinutes < 1)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["durationMinutes"], + message: "Duration is required in timed mode.", + }) + } if (data.mode === "ai") { if (!data.aiSourceText?.trim()) { ctx.addIssue({ @@ -128,6 +151,12 @@ export const defaultValues: Partial = { aiSourceText: "", aiQuestionCount: undefined, aiProviderId: "", + examMode: "homework", + durationMinutes: null, + shuffleQuestions: false, + allowLateStart: false, + lateStartGraceMinutes: 0, + antiCheatEnabled: false, } export const previewTaskStorageKey = "exam-preview-background-tasks:v1" diff --git a/src/modules/exams/components/exam-form.tsx b/src/modules/exams/components/exam-form.tsx index 28b2fb9..42e5f62 100644 --- a/src/modules/exams/components/exam-form.tsx +++ b/src/modules/exams/components/exam-form.tsx @@ -2,7 +2,7 @@ import { useTransition, useEffect, useState, type FormEvent } from "react" import { useRouter } from "next/navigation" -import { useForm } from "react-hook-form" +import { useForm, type Resolver } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { toast } from "sonner" @@ -16,6 +16,7 @@ import { ExamBasicInfoForm } from "./exam-basic-info-form" import { ExamAiGenerator } from "./exam-ai-generator" import { ExamPreviewDialog } from "./exam-preview-dialog" import { ExamModeSelector } from "./exam-mode-selector" +import { ExamModeConfig } from "@/modules/proctoring/components/exam-mode-config" // Re-export formSchema for backward compatibility export { formSchema } from "./exam-form-types" @@ -33,10 +34,11 @@ export function ExamForm() { const [aiProviders, setAiProviders] = useState([]) const [loadingAiProviders, setLoadingAiProviders] = useState(true) + // zodResolver 与 useForm 在含 superRefine + coerce + default 时的输入/输出类型存在协变差异, + // 使用 Resolver 显式标注以替代 as any(从 zodResolver 返回类型收窄) const form = useForm({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - resolver: zodResolver(formSchema) as any, - defaultValues: defaultValues as unknown as ExamFormValues, + resolver: zodResolver(formSchema) as Resolver, + defaultValues, }) const preview = useExamPreview(form) @@ -124,6 +126,15 @@ export function ExamForm() { formData.append("difficulty", Number.isFinite(resolvedDifficulty) && resolvedDifficulty >= 1 && resolvedDifficulty <= 5 ? String(resolvedDifficulty) : "3") formData.append("totalScore", String(resolvedTotalScore)) formData.append("durationMin", String(resolvedDurationMin)) + // P0-3: append exam mode + proctoring config fields + formData.append("examMode", data.examMode ?? "homework") + if (data.durationMinutes !== null && data.durationMinutes !== undefined) { + formData.append("durationMinutes", String(data.durationMinutes)) + } + formData.append("shuffleQuestions", String(data.shuffleQuestions ?? false)) + formData.append("allowLateStart", String(data.allowLateStart ?? false)) + formData.append("lateStartGraceMinutes", String(data.lateStartGraceMinutes ?? 0)) + formData.append("antiCheatEnabled", String(data.antiCheatEnabled ?? false)) if (data.mode === "manual" && data.scheduledAt) { formData.append("scheduledAt", data.scheduledAt) } @@ -159,13 +170,11 @@ export function ExamForm() { preview.handleBackgroundPreview() return } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - form.handleSubmit(onSubmit as any)() + form.handleSubmit(onSubmit)() } const handleConfirmCreate = () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - form.handleSubmit(onSubmit as any)() + form.handleSubmit(onSubmit)() } const handleFormSubmit = (event: FormEvent) => { @@ -224,6 +233,7 @@ export function ExamForm() { queuedPreviewTaskCount={queuedPreviewTaskCount} /> )} + control={form.control} /> diff --git a/src/modules/exams/data-access.ts b/src/modules/exams/data-access.ts index 0e53277..4cb13a3 100644 --- a/src/modules/exams/data-access.ts +++ b/src/modules/exams/data-access.ts @@ -238,6 +238,15 @@ export const buildExamDescription = (input: { questionCount: input.questionCount, }) +export type ExamModeConfig = { + examMode: "homework" | "timed" | "proctored" + durationMinutes: number | null + shuffleQuestions: boolean + allowLateStart: boolean + lateStartGraceMinutes: number + antiCheatEnabled: boolean +} + export const persistExamDraft = async (input: { examId: string title: string @@ -246,6 +255,7 @@ export const persistExamDraft = async (input: { gradeId: string scheduledAt?: string description: string + examModeConfig?: ExamModeConfig }) => { await db.insert(exams).values({ id: input.examId, @@ -256,6 +266,12 @@ export const persistExamDraft = async (input: { gradeId: input.gradeId, startTime: input.scheduledAt ? new Date(input.scheduledAt) : null, status: "draft", + examMode: input.examModeConfig?.examMode ?? "homework", + durationMinutes: input.examModeConfig?.durationMinutes ?? null, + shuffleQuestions: input.examModeConfig?.shuffleQuestions ?? false, + allowLateStart: input.examModeConfig?.allowLateStart ?? false, + lateStartGraceMinutes: input.examModeConfig?.lateStartGraceMinutes ?? 0, + antiCheatEnabled: input.examModeConfig?.antiCheatEnabled ?? false, }) } @@ -294,6 +310,7 @@ export const persistAiGeneratedExamDraft = async (input: { description: string structure: AiGeneratedStructureNode[] generated: AiGeneratedQuestion[] + examModeConfig?: ExamModeConfig }): Promise => { const orderedQuestions = buildOrderedQuestionsFromStructure(input.structure, input.generated) @@ -330,6 +347,12 @@ export const persistAiGeneratedExamDraft = async (input: { startTime: input.scheduledAt ? new Date(input.scheduledAt) : null, status: "draft", structure: input.structure, + examMode: input.examModeConfig?.examMode ?? "homework", + durationMinutes: input.examModeConfig?.durationMinutes ?? null, + shuffleQuestions: input.examModeConfig?.shuffleQuestions ?? false, + allowLateStart: input.examModeConfig?.allowLateStart ?? false, + lateStartGraceMinutes: input.examModeConfig?.lateStartGraceMinutes ?? 0, + antiCheatEnabled: input.examModeConfig?.antiCheatEnabled ?? false, }) if (remappedOrderedQuestions.length > 0) { diff --git a/src/modules/homework/components/assignment-filters.tsx b/src/modules/homework/components/assignment-filters.tsx new file mode 100644 index 0000000..6da053a --- /dev/null +++ b/src/modules/homework/components/assignment-filters.tsx @@ -0,0 +1,50 @@ +"use client" + +import { useQueryState, parseAsString } from "nuqs" + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select" +import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar" + +export function AssignmentFilters() { + const [search, setSearch] = useQueryState("q", parseAsString.withDefault("")) + const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all")) + + const hasFilters = Boolean(search || status !== "all") + + return ( + { + setSearch(null) + setStatus(null) + }} + > + setSearch(v || null)} + placeholder="Search assignments..." + /> + +
+ +
+
+ ) +} diff --git a/src/modules/homework/components/homework-grading-view.tsx b/src/modules/homework/components/homework-grading-view.tsx index a852513..198c30f 100644 --- a/src/modules/homework/components/homework-grading-view.tsx +++ b/src/modules/homework/components/homework-grading-view.tsx @@ -26,8 +26,15 @@ 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 => typeof v === "object" && v !== null +import { QuestionRenderer } from "./question-renderer" +import { + applyAutoGrades as applyAutoGradesUtil, + extractAnswerValue, + getCorrectnessState as getCorrectnessStateUtil, + getOptions, + getTextCorrectAnswers, + isAutoGradable as isAutoGradableUtil, +} from "../lib/question-content-utils" type QuestionContent = { text?: string } & Record @@ -154,180 +161,186 @@ export function HomeworkGradingView({
- {answers.map((ans, index) => ( - 0 ? "border-l-4 border-l-red-500" : "border-l-4 border-l-muted" - }`}> - -
-
-
- - {index + 1} - - - {ans.questionType.replace("_", " ")} - - {isAutoGradable(ans) && ( - {t("homework.grade.autoGraded")} + {answers.map((ans, index) => { + const correctness = getCorrectnessState(ans) + const borderClass = + correctness === "correct" + ? "border-l-4 border-l-emerald-500" + : correctness === "incorrect" + ? "border-l-4 border-l-red-500" + : "border-l-4 border-l-muted" + return ( + + + + + {t("homework.grade.scoreLabel")}: + {ans.score ?? 0} / {ans.maxScore} pts + + {isAutoGradable(ans) && ( + {t("homework.grade.autoGraded")} + )} +
+ } + /> + + + + + + {/* Student Answer Display */} +
+ + +
+ {(ans.questionType === "single_choice" || ans.questionType === "multiple_choice") && + Array.isArray(ans.questionContent?.options) ? ( +
+ {getOptions(ans.questionContent).map((opt) => { + const answerValue = extractAnswerValue(ans.studentAnswer) + const isSelected = Array.isArray(answerValue) + ? answerValue.filter((x): x is string => typeof x === "string").includes(opt.id) + : typeof answerValue === "string" && answerValue === opt.id + + const isCorrect = opt.isCorrect === true + + let containerClass = "border-transparent hover:bg-muted/50" + let indicatorClass = "border-muted-foreground/30 text-muted-foreground" + + if (isSelected) { + if (isCorrect) { + containerClass = "border-emerald-500 bg-emerald-50/50 dark:bg-emerald-950/20" + indicatorClass = "border-emerald-500 bg-emerald-500 text-white" + } else { + containerClass = "border-red-500 bg-red-50/50 dark:bg-red-950/20" + indicatorClass = "border-red-500 bg-red-500 text-white" + } + } else if (isCorrect) { + containerClass = "border-emerald-200 bg-emerald-50/30 dark:border-emerald-800 dark:bg-emerald-950/10" + indicatorClass = "border-emerald-500 text-emerald-600 dark:text-emerald-400" + } + + return ( +
+
+ {opt.id} +
+ {opt.text} + + {isCorrect ? t("homework.grade.correct") : ""} {isSelected && !isCorrect ? t("homework.grade.incorrect") : ""} + + {isCorrect &&
+ ) + })} +
+ ) : ( +

+ {formatStudentAnswer(ans.studentAnswer)} +

)}
- - {ans.questionContent?.text || t("homework.grade.noQuestionText")} -
-
- - {ans.score ?? 0} / {ans.maxScore} pts - -
-
- - - - - - {/* Student Answer Display */} -
- - -
- {(ans.questionType === "single_choice" || ans.questionType === "multiple_choice") && - Array.isArray(ans.questionContent?.options) ? ( -
- {(ans.questionContent.options as ChoiceOption[]).map((opt: ChoiceOption) => { - const isSelected = Array.isArray(extractAnswerValue(ans.studentAnswer)) - ? (extractAnswerValue(ans.studentAnswer) as string[]).includes(opt.id as string) - : extractAnswerValue(ans.studentAnswer) === opt.id - - const isCorrect = opt.isCorrect === true - - // Visual logic: - // If selected and correct -> Green + Check - // If selected and wrong -> Red + X - // If not selected but correct -> Green outline (show missed correct answer) - - let containerClass = "border-transparent hover:bg-muted/50" - let indicatorClass = "border-muted-foreground/30 text-muted-foreground" - - if (isSelected) { - if (isCorrect) { - containerClass = "border-emerald-500 bg-emerald-50/50 dark:bg-emerald-950/20" - indicatorClass = "border-emerald-500 bg-emerald-500 text-white" - } else { - containerClass = "border-red-500 bg-red-50/50 dark:bg-red-950/20" - indicatorClass = "border-red-500 bg-red-500 text-white" - } - } else if (isCorrect) { - containerClass = "border-emerald-200 bg-emerald-50/30 dark:border-emerald-800 dark:bg-emerald-950/10" - indicatorClass = "border-emerald-500 text-emerald-600 dark:text-emerald-400" - } - return ( -
-
- {opt.id as string} -
- {opt.text} - {isCorrect && } - {isSelected && !isCorrect && } -
- ) - })} -
- ) : ( -

- {formatStudentAnswer(ans.studentAnswer)} -

- )} -
-
- - {/* Reference Answer (for text/non-choice questions) */} - {ans.questionType === "text" && ( -
- -
- {getTextCorrectAnswers(ans.questionContent).join(" / ") || t("homework.grade.noReferenceAnswer")} -
-
- )} -
- - -
- {/* Grading Controls */} -
-
- - -
- - - -
- - handleManualScoreChange(ans.id, e.target.value)} - /> - / {ans.maxScore} -
+ {/* Reference Answer (for text/non-choice questions) */} + {ans.questionType === "text" && ( +
+ +
+ {getTextCorrectAnswers(ans.questionContent).join(" / ") || t("homework.grade.noReferenceAnswer")} +
+ )} + - {/* Feedback Toggle */} - -
+ +
+ {/* Grading Controls */} +
+
+ + +
- {/* Feedback Textarea */} - {showFeedbackByAnswerId[ans.id] && ( -
-