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:
SpecialX
2026-06-24 12:03:03 +08:00
parent 0cee93676b
commit f0f713ff33
8 changed files with 623 additions and 25 deletions

View File

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

View File

@@ -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>
<FormLabel>{t("provider.label")}</FormLabel>
<Select value={field.value} onValueChange={field.onChange} disabled={loadingAiProviders}>
<FormControl>
<SelectTrigger>

View 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,
}
}

View File

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

View File

@@ -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
}