feat(exams,homework): add error collection data-access for error book integration
- Add data-access-error-collection in exams module for collecting wrong exam answers - Add data-access-error-collection in homework module for collecting wrong homework answers - Update exams actions, exam-ai-generator, data-access, and types - Update homework actions and data-access-write
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
||||
getExamGrades,
|
||||
getExamPreview,
|
||||
getExamSubjects,
|
||||
getExamsByGradeId,
|
||||
persistAiGeneratedExamDraft,
|
||||
persistExamDraft,
|
||||
resolveSubjectGradeNames,
|
||||
@@ -39,6 +40,7 @@ import type {
|
||||
AiPreviewData,
|
||||
AiRewriteQuestionData,
|
||||
} from "./ai-pipeline"
|
||||
import type { GradeExamsResult } from "./types"
|
||||
export type { AiPreviewData, AiRewriteQuestionData } from "./ai-pipeline"
|
||||
|
||||
const ExamCreateSchema = z.object({
|
||||
@@ -850,4 +852,30 @@ export async function getGradesAction(): Promise<ActionState<{ id: string; name:
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 年级仪表盘 - 维度3:获取年级下所有考试 + 提交统计。
|
||||
*/
|
||||
export async function getExamsByGradeIdAction(
|
||||
gradeId: string
|
||||
): Promise<ActionState<GradeExamsResult>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.EXAM_READ)
|
||||
|
||||
if (!gradeId || gradeId.trim().length === 0) {
|
||||
return failState<GradeExamsResult>("Invalid grade id")
|
||||
}
|
||||
|
||||
const result = await getExamsByGradeId({
|
||||
gradeId,
|
||||
scope: ctx.dataScope,
|
||||
})
|
||||
return successState(result)
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<GradeExamsResult>(error.message)
|
||||
}
|
||||
return handleActionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import type { Control, UseFormReturn } from "react-hook-form"
|
||||
import { useTranslations } from "next-intl"
|
||||
import Link from "next/link"
|
||||
import { Settings } from "lucide-react"
|
||||
import {
|
||||
FormField,
|
||||
FormItem,
|
||||
@@ -86,15 +84,7 @@ export function ExamAiGenerator({
|
||||
name="aiProviderId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FormLabel>{t("provider.label")}</FormLabel>
|
||||
<Button asChild type="button" variant="ghost" size="sm" className="h-7 px-2 text-muted-foreground hover:text-foreground">
|
||||
<Link href="/admin/ai-settings">
|
||||
<Settings className="mr-1 h-3.5 w-3.5" />
|
||||
{t("provider.manage")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Select value={field.value} onValueChange={field.onChange} disabled={loadingAiProviders}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
|
||||
94
src/modules/exams/data-access-error-collection.ts
Normal file
94
src/modules/exams/data-access-error-collection.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import "server-only"
|
||||
|
||||
import { and, eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { examQuestions, examSubmissions, exams, submissionAnswers } from "@/shared/db/schema"
|
||||
|
||||
/**
|
||||
* 错题采集所需的答案数据(单题)
|
||||
*/
|
||||
export type AnswerForErrorCollection = {
|
||||
questionId: string
|
||||
answerContent: unknown
|
||||
score: number | null
|
||||
feedback: string | null
|
||||
maxScore: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 考试提交的错题采集数据
|
||||
*/
|
||||
export type ExamSubmissionDataForErrorCollection = {
|
||||
examId: string
|
||||
subjectId: string | null
|
||||
answers: AnswerForErrorCollection[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨模块接口:获取考试提交的错题采集数据。
|
||||
*
|
||||
* 供 error-book 模块调用,避免 error-book 直接查询 examSubmissions、
|
||||
* submissionAnswers、examQuestions、exams 等属于 exams 模块的表。
|
||||
*
|
||||
* 返回该提交的所有答案(含得分、反馈、满分),由 error-book 模块
|
||||
* 自行筛选错题(score < maxScore)并采集。
|
||||
*
|
||||
* @param submissionId 考试提交 ID
|
||||
* @param studentId 学生 ID(用于校验提交归属)
|
||||
* @returns 提交数据;若提交不存在或 studentId 不匹配则返回 null
|
||||
*/
|
||||
export async function getExamSubmissionDataForErrorCollection(
|
||||
submissionId: string,
|
||||
studentId: string,
|
||||
): Promise<ExamSubmissionDataForErrorCollection | null> {
|
||||
const submission = await db.query.examSubmissions.findFirst({
|
||||
where: and(
|
||||
eq(examSubmissions.id, submissionId),
|
||||
eq(examSubmissions.studentId, studentId),
|
||||
),
|
||||
columns: { id: true, examId: true },
|
||||
})
|
||||
|
||||
if (!submission) return null
|
||||
|
||||
// 并行获取考试学科、提交答案、题目满分
|
||||
const [exam, answers, examQuestionScores] = await Promise.all([
|
||||
db.query.exams.findFirst({
|
||||
where: eq(exams.id, submission.examId),
|
||||
columns: { subjectId: true },
|
||||
}),
|
||||
db
|
||||
.select({
|
||||
questionId: submissionAnswers.questionId,
|
||||
answerContent: submissionAnswers.answerContent,
|
||||
score: submissionAnswers.score,
|
||||
feedback: submissionAnswers.feedback,
|
||||
})
|
||||
.from(submissionAnswers)
|
||||
.where(eq(submissionAnswers.submissionId, submissionId)),
|
||||
db
|
||||
.select({
|
||||
questionId: examQuestions.questionId,
|
||||
maxScore: examQuestions.score,
|
||||
})
|
||||
.from(examQuestions)
|
||||
.where(eq(examQuestions.examId, submission.examId)),
|
||||
])
|
||||
|
||||
const maxScoreMap = new Map(examQuestionScores.map((q) => [q.questionId, q.maxScore ?? 0]))
|
||||
|
||||
const mappedAnswers: AnswerForErrorCollection[] = answers.map((a) => ({
|
||||
questionId: a.questionId,
|
||||
answerContent: a.answerContent,
|
||||
score: a.score,
|
||||
feedback: a.feedback,
|
||||
maxScore: maxScoreMap.get(a.questionId) ?? 0,
|
||||
}))
|
||||
|
||||
return {
|
||||
examId: submission.examId,
|
||||
subjectId: exam?.subjectId ?? null,
|
||||
answers: mappedAnswers,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from "@/shared/db"
|
||||
import { exams, examQuestions, examSubmissions, submissionAnswers } from "@/shared/db/schema"
|
||||
import { count, eq, desc, like, and, or, inArray } from "drizzle-orm"
|
||||
import { exams, examQuestions, examSubmissions, submissionAnswers, questions } from "@/shared/db/schema"
|
||||
import { count, eq, desc, like, and, or, inArray, asc, type SQL } from "drizzle-orm"
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { createQuestionWithRelations } from "@/modules/questions/data-access"
|
||||
@@ -8,7 +8,7 @@ import { getClassGradeIdsByClassIds } from "@/modules/classes/data-access"
|
||||
import { getSubjectNameById, getGradeNameById, getSubjectOptions, getGradeOptions } from "@/modules/school/data-access"
|
||||
import { escapeLikePattern } from "@/shared/lib/action-utils"
|
||||
|
||||
import type { Exam, ExamDifficulty, ExamStatus } from "./types"
|
||||
import type { Exam, ExamDifficulty, ExamStatus, GradeExamsResult, GradeExamItem, ExamForGradeEntry, ExamOptionForEntry } from "./types"
|
||||
import type { AiGeneratedQuestion, AiGeneratedStructureNode } from "./ai-pipeline"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
@@ -786,3 +786,248 @@ export const addExamQuestions = async (
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 年级仪表盘 - 维度3:获取年级下所有考试 + 提交统计。
|
||||
* exams 表有直接 gradeId 字段,配合 examSubmissions 聚合提交数/已评分数/平均分。
|
||||
*/
|
||||
export const getExamsByGradeId = cache(
|
||||
async (params: { gradeId: string; scope: DataScope }): Promise<GradeExamsResult> => {
|
||||
const conditions: SQL[] = [eq(exams.gradeId, params.gradeId)]
|
||||
|
||||
// scope 过滤
|
||||
if (params.scope.type === "owned") {
|
||||
conditions.push(eq(exams.creatorId, params.scope.userId))
|
||||
}
|
||||
if (params.scope.type === "grade_managed") {
|
||||
if (params.scope.gradeIds.length === 0) {
|
||||
conditions.push(eq(exams.id, "__none__"))
|
||||
} else {
|
||||
// grade_managed 且当前 gradeId 在管辖范围内才可见
|
||||
if (!params.scope.gradeIds.includes(params.gradeId)) {
|
||||
return {
|
||||
gradeId: params.gradeId,
|
||||
exams: [],
|
||||
totals: { examCount: 0, publishedCount: 0, draftCount: 0, archivedCount: 0, totalSubmissions: 0, totalGraded: 0 },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (params.scope.type === "class_taught") {
|
||||
// 教师仅能看到所教班级对应年级的考试
|
||||
if (params.scope.classIds.length === 0) {
|
||||
conditions.push(eq(exams.id, "__none__"))
|
||||
} else {
|
||||
const classGradeMap = await getClassGradeIdsByClassIds(params.scope.classIds)
|
||||
const gradeIds = Array.from(new Set(classGradeMap.values()))
|
||||
if (!gradeIds.includes(params.gradeId)) {
|
||||
return {
|
||||
gradeId: params.gradeId,
|
||||
exams: [],
|
||||
totals: { examCount: 0, publishedCount: 0, draftCount: 0, archivedCount: 0, totalSubmissions: 0, totalGraded: 0 },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const examRows = await db
|
||||
.select({
|
||||
id: exams.id,
|
||||
title: exams.title,
|
||||
status: exams.status,
|
||||
subjectId: exams.subjectId,
|
||||
startTime: exams.startTime,
|
||||
createdAt: exams.createdAt,
|
||||
})
|
||||
.from(exams)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(exams.createdAt))
|
||||
|
||||
if (examRows.length === 0) {
|
||||
return {
|
||||
gradeId: params.gradeId,
|
||||
exams: [],
|
||||
totals: { examCount: 0, publishedCount: 0, draftCount: 0, archivedCount: 0, totalSubmissions: 0, totalGraded: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
const examIds = examRows.map((e) => e.id)
|
||||
|
||||
// 并行查询:科目名称 + 提交统计
|
||||
const [subjectOptions, submissionRows] = await Promise.all([
|
||||
getSubjectOptions(),
|
||||
db
|
||||
.select({
|
||||
examId: examSubmissions.examId,
|
||||
status: examSubmissions.status,
|
||||
score: examSubmissions.score,
|
||||
})
|
||||
.from(examSubmissions)
|
||||
.where(inArray(examSubmissions.examId, examIds)),
|
||||
])
|
||||
|
||||
const subjectNameById = new Map<string, string>()
|
||||
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
|
||||
|
||||
// 按考试分组统计提交
|
||||
const statsByExam = new Map<string, { total: number; graded: number; scoreSum: number }>()
|
||||
for (const s of submissionRows) {
|
||||
const entry = statsByExam.get(s.examId) ?? { total: 0, graded: 0, scoreSum: 0 }
|
||||
entry.total += 1
|
||||
if (s.status === "graded" && s.score !== null) {
|
||||
entry.graded += 1
|
||||
entry.scoreSum += Number(s.score)
|
||||
}
|
||||
statsByExam.set(s.examId, entry)
|
||||
}
|
||||
|
||||
const items: GradeExamItem[] = examRows.map((e) => {
|
||||
const stats = statsByExam.get(e.id) ?? { total: 0, graded: 0, scoreSum: 0 }
|
||||
return {
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
status: toExamStatus(e.status),
|
||||
subjectId: e.subjectId,
|
||||
subjectName: e.subjectId ? (subjectNameById.get(e.subjectId) ?? null) : null,
|
||||
scheduledAt: e.startTime ? e.startTime.toISOString() : null,
|
||||
createdAt: e.createdAt.toISOString(),
|
||||
submissionCount: stats.total,
|
||||
gradedCount: stats.graded,
|
||||
averageScore: stats.graded > 0 ? Math.round((stats.scoreSum / stats.graded) * 100) / 100 : null,
|
||||
}
|
||||
})
|
||||
|
||||
const totals = {
|
||||
examCount: items.length,
|
||||
publishedCount: items.filter((i) => i.status === "published").length,
|
||||
draftCount: items.filter((i) => i.status === "draft").length,
|
||||
archivedCount: items.filter((i) => i.status === "archived").length,
|
||||
totalSubmissions: items.reduce((sum, i) => sum + i.submissionCount, 0),
|
||||
totalGraded: items.reduce((sum, i) => sum + i.gradedCount, 0),
|
||||
}
|
||||
|
||||
return { gradeId: params.gradeId, exams: items, totals }
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取可用于成绩录入的试卷列表(按 scope 过滤,只返回有题目的试卷)。
|
||||
*/
|
||||
export const getExamsForGradeEntry = cache(
|
||||
async (scope: DataScope): Promise<ExamOptionForEntry[]> => {
|
||||
const conditions: SQL[] = []
|
||||
|
||||
if (scope.type === "owned") {
|
||||
conditions.push(eq(exams.creatorId, scope.userId))
|
||||
}
|
||||
if (scope.type === "class_taught" && scope.classIds.length > 0) {
|
||||
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
|
||||
const gradeIds = Array.from(new Set(classGradeMap.values()))
|
||||
if (gradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, gradeIds))
|
||||
} else {
|
||||
conditions.push(eq(exams.id, "__none__"))
|
||||
}
|
||||
} else if (scope.type === "class_taught") {
|
||||
conditions.push(eq(exams.id, "__none__"))
|
||||
}
|
||||
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, scope.gradeIds))
|
||||
} else if (scope.type === "grade_managed") {
|
||||
conditions.push(eq(exams.id, "__none__"))
|
||||
}
|
||||
|
||||
const examRows = await db.query.exams.findMany({
|
||||
where: conditions.length ? and(...conditions) : undefined,
|
||||
orderBy: [desc(exams.createdAt)],
|
||||
with: { subject: true, gradeEntity: true },
|
||||
})
|
||||
|
||||
if (examRows.length === 0) return []
|
||||
|
||||
const examIds = examRows.map((e) => e.id)
|
||||
const questionCountRows = await db
|
||||
.select({ examId: examQuestions.examId, count: count() })
|
||||
.from(examQuestions)
|
||||
.where(inArray(examQuestions.examId, examIds))
|
||||
.groupBy(examQuestions.examId)
|
||||
|
||||
const questionCountMap = new Map(
|
||||
questionCountRows.map((r) => [r.examId, Number(r.count)])
|
||||
)
|
||||
|
||||
return examRows
|
||||
.filter((e) => (questionCountMap.get(e.id) ?? 0) > 0)
|
||||
.map((e) => {
|
||||
const meta = parseExamMeta(e.description ?? null)
|
||||
return {
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
subjectName: e.subject?.name ?? getString(meta, "subject") ?? "General",
|
||||
gradeName: e.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
|
||||
questionCount: questionCountMap.get(e.id) ?? 0,
|
||||
totalScore: getNumber(meta, "totalScore") ?? 100,
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 获取单个试卷详情(含题目列表),用于成绩录入表格表头。
|
||||
*/
|
||||
export const getExamForGradeEntry = cache(
|
||||
async (examId: string, scope?: DataScope): Promise<ExamForGradeEntry | null> => {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
with: { subject: true, gradeEntity: true },
|
||||
})
|
||||
|
||||
if (!exam) return null
|
||||
|
||||
if (scope && scope.type !== "all") {
|
||||
if (scope.type === "owned" && exam.creatorId !== scope.userId) return null
|
||||
if (scope.type === "grade_managed") {
|
||||
if (scope.gradeIds.length === 0) return null
|
||||
if (!scope.gradeIds.includes(exam.gradeId ?? "")) return null
|
||||
}
|
||||
if (scope.type === "class_taught") {
|
||||
if (scope.classIds.length === 0) return null
|
||||
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
|
||||
const gradeIds = Array.from(new Set(classGradeMap.values()))
|
||||
if (gradeIds.length === 0) return null
|
||||
if (!gradeIds.includes(exam.gradeId ?? "")) return null
|
||||
}
|
||||
}
|
||||
|
||||
const questionRows = await db
|
||||
.select({
|
||||
questionId: examQuestions.questionId,
|
||||
score: examQuestions.score,
|
||||
order: examQuestions.order,
|
||||
type: questions.type,
|
||||
})
|
||||
.from(examQuestions)
|
||||
.innerJoin(questions, eq(examQuestions.questionId, questions.id))
|
||||
.where(eq(examQuestions.examId, examId))
|
||||
.orderBy(asc(examQuestions.order))
|
||||
|
||||
if (questionRows.length === 0) return null
|
||||
|
||||
const meta = parseExamMeta(exam.description ?? null)
|
||||
const computedTotal = questionRows.reduce((sum, q) => sum + (q.score ?? 0), 0)
|
||||
|
||||
return {
|
||||
id: exam.id,
|
||||
title: exam.title,
|
||||
subjectId: exam.subjectId,
|
||||
gradeId: exam.gradeId,
|
||||
totalScore: getNumber(meta, "totalScore") ?? computedTotal,
|
||||
questions: questionRows.map((q) => ({
|
||||
id: q.questionId,
|
||||
order: q.order ?? 0,
|
||||
score: q.score ?? 0,
|
||||
type: q.type,
|
||||
})),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -30,3 +30,65 @@ export interface ExamSubmission {
|
||||
status: SubmissionStatus
|
||||
}
|
||||
|
||||
/**
|
||||
* 年级仪表盘 - 维度3:年级下考试列表项(含提交统计)。
|
||||
*/
|
||||
export interface GradeExamItem {
|
||||
id: string
|
||||
title: string
|
||||
status: ExamStatus
|
||||
subjectId: string | null
|
||||
subjectName: string | null
|
||||
scheduledAt: string | null
|
||||
createdAt: string
|
||||
/** 提交记录总数(started + submitted + graded) */
|
||||
submissionCount: number
|
||||
/** 已评分提交数 */
|
||||
gradedCount: number
|
||||
/** 已评分提交的平均分 */
|
||||
averageScore: number | null
|
||||
}
|
||||
|
||||
export interface GradeExamsResult {
|
||||
gradeId: string
|
||||
exams: GradeExamItem[]
|
||||
totals: {
|
||||
examCount: number
|
||||
publishedCount: number
|
||||
draftCount: number
|
||||
archivedCount: number
|
||||
totalSubmissions: number
|
||||
totalGraded: number
|
||||
}
|
||||
}
|
||||
|
||||
// --- 成绩录入用的试卷类型 ---
|
||||
|
||||
/** 试卷题目项(用于录入表格表头) */
|
||||
export interface ExamQuestionItem {
|
||||
id: string
|
||||
order: number
|
||||
score: number
|
||||
type: string
|
||||
}
|
||||
|
||||
/** 试卷信息(用于录入,含题目列表) */
|
||||
export interface ExamForGradeEntry {
|
||||
id: string
|
||||
title: string
|
||||
subjectId: string | null
|
||||
gradeId: string | null
|
||||
totalScore: number
|
||||
questions: ExamQuestionItem[]
|
||||
}
|
||||
|
||||
/** 试卷列表项(用于选择器) */
|
||||
export interface ExamOptionForEntry {
|
||||
id: string
|
||||
title: string
|
||||
subjectName: string
|
||||
gradeName: string
|
||||
questionCount: number
|
||||
totalScore: number
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { handleActionError, safeJsonParse, safeParseDate } from "@/shared/lib/action-utils"
|
||||
import { trackExamEvent } from "@/shared/lib/track-event"
|
||||
import { collectFromHomeworkSubmission } from "@/modules/error-book/data-access-collection"
|
||||
import { updateMasteryFromHomeworkSubmission } from "@/modules/diagnostic/data-access"
|
||||
|
||||
import { CreateHomeworkAssignmentSchema, GradeHomeworkSchema } from "./schema"
|
||||
import {
|
||||
@@ -32,6 +34,26 @@ const parseStudentIds = (raw: string): string[] => {
|
||||
.filter((s) => s.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批改后处理:自动采集错题 + 更新知识点掌握度。
|
||||
*
|
||||
* 使用 Promise.allSettled 并行执行,确保一个失败不影响另一个。
|
||||
* 错误被记录但不抛出,不影响批改操作的成功返回。
|
||||
*/
|
||||
async function runPostGradingHooks(submissionId: string, studentId: string): Promise<void> {
|
||||
const [errorBookResult, masteryResult] = await Promise.allSettled([
|
||||
collectFromHomeworkSubmission(submissionId, studentId),
|
||||
updateMasteryFromHomeworkSubmission(submissionId),
|
||||
])
|
||||
|
||||
if (errorBookResult.status === "rejected") {
|
||||
console.error(`[post-grading] 错题采集失败 submission=${submissionId}:`, errorBookResult.reason)
|
||||
}
|
||||
if (masteryResult.status === "rejected") {
|
||||
console.error(`[post-grading] 掌握度更新失败 submission=${submissionId}:`, masteryResult.reason)
|
||||
}
|
||||
}
|
||||
|
||||
export async function createHomeworkAssignmentAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
@@ -245,6 +267,11 @@ export async function submitHomeworkAction(
|
||||
// V3-2: 即时自动批改回写
|
||||
const { isFullyAutoGraded, totalScore } = await markHomeworkSubmitted(submissionId, isLate)
|
||||
|
||||
// 批改完成后自动采集错题 + 更新掌握度(仅当全部自动批改完成时)
|
||||
if (isFullyAutoGraded) {
|
||||
await runPostGradingHooks(submissionId, ctx.userId)
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/homework/submissions")
|
||||
revalidatePath("/student/learning/assignments")
|
||||
|
||||
@@ -293,17 +320,15 @@ export async function gradeHomeworkSubmissionAction(
|
||||
|
||||
const { submissionId, answers } = parsed.data
|
||||
|
||||
// 权限二次校验:非管理员仅可批改自己创建的作业提交
|
||||
// 管理员(dataScope.type === "all")可批改所有提交
|
||||
if (ctx.dataScope.type !== "all") {
|
||||
// 权限二次校验 + 获取 studentId(用于批改后处理)
|
||||
// 非管理员仅可批改自己创建的作业提交;管理员(dataScope.type === "all")可批改所有提交
|
||||
const submissionForGrading = await getHomeworkSubmissionForGrading(submissionId)
|
||||
if (!submissionForGrading) {
|
||||
return { success: false, message: "Submission not found" }
|
||||
}
|
||||
if (submissionForGrading.creatorId !== ctx.userId) {
|
||||
if (ctx.dataScope.type !== "all" && submissionForGrading.creatorId !== ctx.userId) {
|
||||
return { success: false, message: "You can only grade submissions for your own assignments" }
|
||||
}
|
||||
}
|
||||
|
||||
await gradeHomeworkAnswers(
|
||||
submissionId,
|
||||
@@ -314,6 +339,9 @@ export async function gradeHomeworkSubmissionAction(
|
||||
}))
|
||||
)
|
||||
|
||||
// 批改完成后自动采集错题 + 更新掌握度
|
||||
await runPostGradingHooks(submissionId, submissionForGrading.studentId)
|
||||
|
||||
revalidatePath("/teacher/homework/submissions")
|
||||
|
||||
// V3-4: 埋点监控
|
||||
@@ -373,6 +401,16 @@ export async function batchAutoGradeSubmissionsAction(
|
||||
const failedCount = results.filter((r) => !r.success).length
|
||||
const fullyGradedCount = results.filter((r) => r.success && r.isFullyAutoGraded).length
|
||||
|
||||
// 批改完成后自动采集错题 + 更新掌握度(仅对成功批改的提交)
|
||||
await Promise.allSettled(
|
||||
results
|
||||
.filter(
|
||||
(r): r is typeof r & { studentId: string } =>
|
||||
r.success && typeof r.studentId === "string" && r.studentId.length > 0,
|
||||
)
|
||||
.map((r) => runPostGradingHooks(r.submissionId, r.studentId)),
|
||||
)
|
||||
|
||||
revalidatePath("/teacher/homework/submissions")
|
||||
revalidatePath("/teacher/homework/assignments")
|
||||
|
||||
|
||||
136
src/modules/homework/data-access-error-collection.ts
Normal file
136
src/modules/homework/data-access-error-collection.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import "server-only"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
homeworkAnswers,
|
||||
homeworkAssignmentQuestions,
|
||||
homeworkAssignments,
|
||||
homeworkSubmissions,
|
||||
} from "@/shared/db/schema"
|
||||
import { getExamSubjectIdMap } from "@/modules/exams/data-access"
|
||||
|
||||
/**
|
||||
* 错题采集所需的答案数据(单题)
|
||||
*/
|
||||
export type AnswerForErrorCollection = {
|
||||
questionId: string
|
||||
answerContent: unknown
|
||||
score: number | null
|
||||
feedback: string | null
|
||||
maxScore: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 作业提交的错题采集数据
|
||||
*/
|
||||
export type HomeworkSubmissionDataForErrorCollection = {
|
||||
assignmentId: string
|
||||
subjectId: string | null
|
||||
answers: AnswerForErrorCollection[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨模块接口:获取作业提交的错题采集数据。
|
||||
*
|
||||
* 供 error-book 模块调用,避免 error-book 直接查询 homeworkSubmissions、
|
||||
* homeworkAssignments、homeworkAnswers、homeworkAssignmentQuestions 等属于
|
||||
* homework 模块的表,以及 exams 表(通过 exams 模块的 getExamSubjectIdMap 获取)。
|
||||
*
|
||||
* 返回该提交的所有答案(含得分、反馈、满分),由 error-book 模块
|
||||
* 自行筛选错题(score < maxScore)并采集。
|
||||
*
|
||||
* @param submissionId 作业提交 ID
|
||||
* @returns 提交数据;若提交不存在则返回 null
|
||||
*/
|
||||
export async function getHomeworkSubmissionDataForErrorCollection(
|
||||
submissionId: string,
|
||||
): Promise<HomeworkSubmissionDataForErrorCollection | null> {
|
||||
const submission = await db.query.homeworkSubmissions.findFirst({
|
||||
where: eq(homeworkSubmissions.id, submissionId),
|
||||
columns: { id: true, assignmentId: true },
|
||||
})
|
||||
|
||||
if (!submission) return null
|
||||
|
||||
// 并行获取作业信息、提交答案、题目满分
|
||||
const [assignment, answers, hwQuestionScores] = await Promise.all([
|
||||
db.query.homeworkAssignments.findFirst({
|
||||
where: eq(homeworkAssignments.id, submission.assignmentId),
|
||||
columns: { id: true, sourceExamId: true },
|
||||
}),
|
||||
db
|
||||
.select({
|
||||
questionId: homeworkAnswers.questionId,
|
||||
answerContent: homeworkAnswers.answerContent,
|
||||
score: homeworkAnswers.score,
|
||||
feedback: homeworkAnswers.feedback,
|
||||
})
|
||||
.from(homeworkAnswers)
|
||||
.where(eq(homeworkAnswers.submissionId, submissionId)),
|
||||
db
|
||||
.select({
|
||||
questionId: homeworkAssignmentQuestions.questionId,
|
||||
maxScore: homeworkAssignmentQuestions.score,
|
||||
})
|
||||
.from(homeworkAssignmentQuestions)
|
||||
.where(eq(homeworkAssignmentQuestions.assignmentId, submission.assignmentId)),
|
||||
])
|
||||
|
||||
// 获取学科 ID:若作业派生自试卷,从源试卷获取 subjectId
|
||||
let subjectId: string | null = null
|
||||
if (assignment?.sourceExamId) {
|
||||
const subjectIdMap = await getExamSubjectIdMap([assignment.sourceExamId])
|
||||
subjectId = subjectIdMap.get(assignment.sourceExamId) ?? null
|
||||
}
|
||||
|
||||
const maxScoreMap = new Map(hwQuestionScores.map((q) => [q.questionId, q.maxScore ?? 0]))
|
||||
|
||||
const mappedAnswers: AnswerForErrorCollection[] = answers.map((a) => ({
|
||||
questionId: a.questionId,
|
||||
answerContent: a.answerContent,
|
||||
score: a.score,
|
||||
feedback: a.feedback,
|
||||
maxScore: maxScoreMap.get(a.questionId) ?? 0,
|
||||
}))
|
||||
|
||||
return {
|
||||
assignmentId: submission.assignmentId,
|
||||
subjectId,
|
||||
answers: mappedAnswers,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跨模块接口:获取作业提交的答案数据(供 diagnostic 模块更新掌握度使用)。
|
||||
*
|
||||
* 返回格式与 exams 模块的 `getExamSubmissionWithAnswers` 一致,
|
||||
* 便于 diagnostic 模块统一处理考试提交和作业提交的掌握度更新。
|
||||
*
|
||||
* @param submissionId 作业提交 ID
|
||||
* @returns `{ studentId, answers: Array<{ questionId, score }> }`;若提交不存在则返回 null
|
||||
*/
|
||||
export async function getHomeworkSubmissionWithAnswersForMastery(
|
||||
submissionId: string,
|
||||
): Promise<{ studentId: string; answers: Array<{ questionId: string; score: number | null }> } | null> {
|
||||
const submission = await db.query.homeworkSubmissions.findFirst({
|
||||
where: eq(homeworkSubmissions.id, submissionId),
|
||||
columns: { studentId: true },
|
||||
})
|
||||
|
||||
if (!submission) return null
|
||||
|
||||
const answers = await db
|
||||
.select({
|
||||
questionId: homeworkAnswers.questionId,
|
||||
score: homeworkAnswers.score,
|
||||
})
|
||||
.from(homeworkAnswers)
|
||||
.where(eq(homeworkAnswers.submissionId, submissionId))
|
||||
|
||||
return {
|
||||
studentId: submission.studentId,
|
||||
answers,
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,7 @@ export const getHomeworkSubmissionForGrading = async (
|
||||
assignmentId: string
|
||||
creatorId: string
|
||||
sourceExamId: string | null
|
||||
studentId: string
|
||||
} | null> => {
|
||||
const submission = await db.query.homeworkSubmissions.findFirst({
|
||||
where: eq(homeworkSubmissions.id, submissionId),
|
||||
@@ -143,6 +144,7 @@ export const getHomeworkSubmissionForGrading = async (
|
||||
assignmentId: submission.assignmentId,
|
||||
creatorId: submission.assignment.creatorId,
|
||||
sourceExamId: submission.assignment.sourceExamId,
|
||||
studentId: submission.studentId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,6 +397,7 @@ export const batchAutoGradeSubmissions = async (
|
||||
success: boolean
|
||||
isFullyAutoGraded: boolean
|
||||
totalScore: number
|
||||
studentId?: string
|
||||
message?: string
|
||||
}>> => {
|
||||
const now = new Date()
|
||||
@@ -403,6 +406,7 @@ export const batchAutoGradeSubmissions = async (
|
||||
success: boolean
|
||||
isFullyAutoGraded: boolean
|
||||
totalScore: number
|
||||
studentId?: string
|
||||
message?: string
|
||||
}> = []
|
||||
|
||||
@@ -410,7 +414,7 @@ export const batchAutoGradeSubmissions = async (
|
||||
try {
|
||||
const submission = await db.query.homeworkSubmissions.findFirst({
|
||||
where: eq(homeworkSubmissions.id, submissionId),
|
||||
columns: { id: true, assignmentId: true },
|
||||
columns: { id: true, assignmentId: true, studentId: true },
|
||||
})
|
||||
|
||||
if (!submission) {
|
||||
@@ -483,6 +487,7 @@ export const batchAutoGradeSubmissions = async (
|
||||
success: true,
|
||||
isFullyAutoGraded,
|
||||
totalScore,
|
||||
studentId: submission.studentId,
|
||||
})
|
||||
} catch (error) {
|
||||
results.push({
|
||||
|
||||
Reference in New Issue
Block a user