refactor(modules): update existing module implementations across attendance, audit, auth, classes, course-plans, exams, files, homework, layout, proctoring, questions, scheduling, textbooks, users
- Update attendance components and data-access for record management - Update audit log views, filters, and data-access - Update auth login and register forms - Update classes actions, components, and data-access (admin, schedule, stats) - Update course-plans actions, form, list, progress, and schema - Update exams actions, AI pipeline, preview components, and hooks - Update files components (icon, list, preview, upload) and data-access - Update homework assignment form, review view, auto-save hook, and stats-service - Update layout sidebar, header, and navigation config - Update proctoring actions, anti-cheat monitor, and data-access - Update questions actions, components (dialog, actions, columns, filters), and data-access - Update scheduling actions, auto-scheduler, components, and schema - Update textbooks constants and text-selection hook - Update users class-registration, import-dialog, data-access, and user-service
This commit is contained in:
@@ -72,13 +72,18 @@ export function HomeworkAssignmentForm({ exams, classes }: { exams: ExamOption[]
|
||||
formData.set("publish", "true")
|
||||
|
||||
setIsSubmitting(true)
|
||||
const result = await createHomeworkAssignmentAction(null, formData)
|
||||
setIsSubmitting(false)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
router.push("/teacher/homework/assignments")
|
||||
} else {
|
||||
toast.error(result.message || t("homework.form.createFailed"))
|
||||
try {
|
||||
const result = await createHomeworkAssignmentAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
router.push("/teacher/homework/assignments")
|
||||
} else {
|
||||
toast.error(result.message || t("homework.form.createFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("homework.form.createFailed"))
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,8 +126,8 @@ export function HomeworkAssignmentQuestionErrorDetailPanel({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{wrongAnswers.map((wa, i) => (
|
||||
<div key={i} className="rounded-md border bg-background p-3 text-sm shadow-sm">
|
||||
{wrongAnswers.map((wa) => (
|
||||
<div key={wa.studentId} className="rounded-md border bg-background p-3 text-sm shadow-sm">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">Student Answer</span>
|
||||
<span className="text-xs text-muted-foreground">{wa.count ?? 1} student{(wa.count ?? 1) > 1 ? "s" : ""}</span>
|
||||
|
||||
@@ -186,11 +186,10 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
|
||||
</Label>
|
||||
<div className="mt-2 grid grid-cols-5 gap-2">
|
||||
{initialData.questions.map((q, i) => {
|
||||
const hasAnswer = answersByQuestionId[q.questionId]?.answer !== undefined &&
|
||||
answersByQuestionId[q.questionId]?.answer !== "" &&
|
||||
(Array.isArray(answersByQuestionId[q.questionId]?.answer)
|
||||
? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0
|
||||
: true)
|
||||
const answer = answersByQuestionId[q.questionId]?.answer
|
||||
const hasAnswer = answer !== undefined &&
|
||||
answer !== "" &&
|
||||
(Array.isArray(answer) ? answer.length > 0 : true)
|
||||
|
||||
const score = q.score ?? 0
|
||||
const max = q.maxScore
|
||||
|
||||
@@ -70,27 +70,40 @@ export function useDebouncedAutoSave({
|
||||
savingRef.current = true
|
||||
setStatus("saving")
|
||||
|
||||
let allOk = true
|
||||
for (const [questionId, answer] of pending) {
|
||||
const fd = new FormData()
|
||||
fd.set("submissionId", submissionId)
|
||||
fd.set("questionId", questionId)
|
||||
fd.set("answerJson", JSON.stringify({ answer }))
|
||||
const res = await saveHomeworkAnswerAction(null, fd)
|
||||
if (!res.success) {
|
||||
allOk = false
|
||||
}
|
||||
}
|
||||
// 并行保存所有待保存答案,单个失败不影响其他答案
|
||||
const results = await Promise.allSettled(
|
||||
pending.map(([questionId, answer]) => {
|
||||
const fd = new FormData()
|
||||
fd.set("submissionId", submissionId)
|
||||
fd.set("questionId", questionId)
|
||||
fd.set("answerJson", JSON.stringify({ answer }))
|
||||
return saveHomeworkAnswerAction(null, fd)
|
||||
})
|
||||
)
|
||||
|
||||
savingRef.current = false
|
||||
|
||||
if (allOk) {
|
||||
// 收集失败的 questionId 以便重试
|
||||
const failedQuestionIds: string[] = []
|
||||
results.forEach((res, idx) => {
|
||||
if (res.status !== "fulfilled" || !res.value.success) {
|
||||
failedQuestionIds.push(pending[idx][0])
|
||||
}
|
||||
})
|
||||
|
||||
if (failedQuestionIds.length === 0) {
|
||||
pendingRef.current.clear()
|
||||
setStatus("saved")
|
||||
setLastSavedAt(Date.now())
|
||||
} else {
|
||||
setStatus("error")
|
||||
// Keep pending items for retry on next change or manual flush
|
||||
// 仅保留失败的项用于重试,移除已成功的项
|
||||
const newPending = new Map<string, unknown>()
|
||||
for (const qid of failedQuestionIds) {
|
||||
const ans = pendingRef.current.get(qid)
|
||||
if (ans !== undefined) newPending.set(qid, ans)
|
||||
}
|
||||
pendingRef.current = newPending
|
||||
}
|
||||
}, [submissionId])
|
||||
|
||||
@@ -135,7 +148,12 @@ export function useDebouncedAutoSave({
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
// Fire-and-forget final save
|
||||
// 注意:此处无法使用 navigator.sendBeacon,因为保存逻辑调用的是
|
||||
// Next.js Server Action(基于 fetch 的 RPC),而非简单的 POST 请求。
|
||||
// sendBeacon 仅支持发送原始 body,无法携带 Server Action 所需的
|
||||
// 特定 headers 和编码格式。因此采用 fire-and-forget 方式触发最后的
|
||||
// 保存,并依赖 localStorage 离线缓存作为兜底(下次进入页面会恢复)。
|
||||
// 真正的可靠 flush 由 handleSubmit 在提交前调用 autoSave.flush() 保证。
|
||||
void savePending()
|
||||
}
|
||||
}, [savePending])
|
||||
|
||||
@@ -161,6 +161,49 @@ export const computeIsCorrect = (input: {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算多选题部分分比例(V3-6: 漏选得部分分)
|
||||
*
|
||||
* 评分策略:
|
||||
* - 全部正确选项都选中且无错误选项 → 1.0(满分)
|
||||
* - 部分正确选项被选中且无错误选项 → 正确选项数 / 总正确选项数
|
||||
* - 包含错误选项 → 0(鼓励不猜题)
|
||||
* - 无标准答案 → null(不自动判分)
|
||||
*
|
||||
* @example
|
||||
* correctIds=[A,B,C], studentIds=[A,B] → 2/3 ≈ 0.667
|
||||
* correctIds=[A,B,C], studentIds=[A,B,D] → 0(含错误选项 D)
|
||||
* correctIds=[A,B,C], studentIds=[A,B,C] → 1.0
|
||||
*/
|
||||
export const computeMultipleChoicePartialRatio = (input: {
|
||||
questionContent: unknown
|
||||
studentAnswer: unknown
|
||||
}): number | null => {
|
||||
const correctIds = getChoiceCorrectIds(input.questionContent)
|
||||
if (correctIds.length === 0) return null
|
||||
|
||||
const studentVal = extractAnswerValue(input.studentAnswer)
|
||||
const studentArr = Array.isArray(studentVal)
|
||||
? studentVal.filter((x): x is string => typeof x === "string")
|
||||
: []
|
||||
|
||||
const correctSet = new Set(correctIds)
|
||||
const studentSet = new Set(studentArr)
|
||||
|
||||
// 检查是否有错误选项(学生选了不在正确答案中的选项)
|
||||
for (const id of studentSet) {
|
||||
if (!correctSet.has(id)) return 0
|
||||
}
|
||||
|
||||
// 无错误选项,按正确选项比例给分
|
||||
let correctSelected = 0
|
||||
for (const id of studentSet) {
|
||||
if (correctSet.has(id)) correctSelected += 1
|
||||
}
|
||||
|
||||
return correctSelected / correctIds.length
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据分数与满分推断对错状态
|
||||
*/
|
||||
@@ -193,7 +236,12 @@ export interface AutoGradableAnswer {
|
||||
* 对未判分的题目应用自动判分
|
||||
* - 已有分数(score !== null)的不覆盖
|
||||
* - 无标准答案的不判分
|
||||
* - 否则按 computeIsCorrect 给满分或 0 分
|
||||
* - 多选题支持部分分(漏选得部分分,错选得 0 分)
|
||||
* - 其他题型按 computeIsCorrect 给满分或 0 分
|
||||
*
|
||||
* V3-6: 多选题部分分策略
|
||||
* 使用 computeMultipleChoicePartialRatio 计算比例分数
|
||||
* 例如:maxScore=6, 正确选项[A,B,C], 学生选[A,B] → 6 * (2/3) = 4 分
|
||||
*/
|
||||
export const applyAutoGrades = <T extends AutoGradableAnswer>(incoming: T[]): T[] => {
|
||||
return incoming.map((a) => {
|
||||
@@ -201,6 +249,19 @@ export const applyAutoGrades = <T extends AutoGradableAnswer>(incoming: T[]): T[
|
||||
if (!isAutoGradable({ questionType: a.questionType, questionContent: a.questionContent })) {
|
||||
return a
|
||||
}
|
||||
|
||||
// V3-6: 多选题使用部分分策略
|
||||
if (a.questionType === "multiple_choice") {
|
||||
const ratio = computeMultipleChoicePartialRatio({
|
||||
questionContent: a.questionContent,
|
||||
studentAnswer: a.studentAnswer,
|
||||
})
|
||||
if (ratio === null) return a
|
||||
// 按比例计算分数,四舍五入到整数(DB schema score 为整数)
|
||||
const scaledScore = Math.round(a.maxScore * ratio)
|
||||
return { ...a, score: scaledScore }
|
||||
}
|
||||
|
||||
const isCorrect = computeIsCorrect({
|
||||
questionType: a.questionType,
|
||||
questionContent: a.questionContent,
|
||||
@@ -211,6 +272,63 @@ export const applyAutoGrades = <T extends AutoGradableAnswer>(incoming: T[]): T[
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* V3-2: 服务端即时自动批改
|
||||
*
|
||||
* 与 applyAutoGrades 类似,但用于学生提交时服务端回写。
|
||||
* 返回批改结果数组(含 score 和 feedback),以及是否全部可自动判分。
|
||||
*
|
||||
* @returns { answers: 批改后的答案数组, isFullyAutoGraded: 是否全部题目可自动判分 }
|
||||
*/
|
||||
export const autoGradeSubmission = <T extends AutoGradableAnswer>(
|
||||
incoming: T[]
|
||||
): { answers: Array<T & { score: number }>; isFullyAutoGraded: boolean } => {
|
||||
const graded: Array<T & { score: number }> = []
|
||||
let hasUngradable = false
|
||||
|
||||
for (const a of incoming) {
|
||||
if (!isAutoGradable({ questionType: a.questionType, questionContent: a.questionContent })) {
|
||||
// 主观题:保留原 score(可能为 null),标记为不可全自动判分
|
||||
hasUngradable = true
|
||||
graded.push({ ...a, score: a.score ?? 0 })
|
||||
continue
|
||||
}
|
||||
|
||||
// 多选题使用部分分策略
|
||||
if (a.questionType === "multiple_choice") {
|
||||
const ratio = computeMultipleChoicePartialRatio({
|
||||
questionContent: a.questionContent,
|
||||
studentAnswer: a.studentAnswer,
|
||||
})
|
||||
if (ratio === null) {
|
||||
hasUngradable = true
|
||||
graded.push({ ...a, score: a.score ?? 0 })
|
||||
continue
|
||||
}
|
||||
const scaledScore = Math.round(a.maxScore * ratio)
|
||||
graded.push({ ...a, score: scaledScore })
|
||||
continue
|
||||
}
|
||||
|
||||
const isCorrect = computeIsCorrect({
|
||||
questionType: a.questionType,
|
||||
questionContent: a.questionContent,
|
||||
studentAnswer: a.studentAnswer,
|
||||
})
|
||||
if (isCorrect === null) {
|
||||
hasUngradable = true
|
||||
graded.push({ ...a, score: a.score ?? 0 })
|
||||
continue
|
||||
}
|
||||
graded.push({ ...a, score: isCorrect ? a.maxScore : 0 })
|
||||
}
|
||||
|
||||
return {
|
||||
answers: graded,
|
||||
isFullyAutoGraded: !hasUngradable,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化学生答案为可读字符串
|
||||
*/
|
||||
|
||||
@@ -1,18 +1,51 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const CreateHomeworkAssignmentSchema = z.object({
|
||||
sourceExamId: z.string().optional(),
|
||||
classId: z.string().min(1),
|
||||
title: z.string().min(1, "Title is required for quick assignments"),
|
||||
description: z.string().optional(),
|
||||
availableAt: z.string().optional(),
|
||||
dueAt: z.string().optional(),
|
||||
allowLate: z.coerce.boolean().optional(),
|
||||
lateDueAt: z.string().optional(),
|
||||
maxAttempts: z.coerce.number().int().min(1).max(20).optional(),
|
||||
targetStudentIds: z.array(z.string().min(1)).optional(),
|
||||
publish: z.coerce.boolean().optional(),
|
||||
})
|
||||
const dateStringSchema = z
|
||||
.string()
|
||||
.refine((v) => !Number.isNaN(new Date(v).getTime()), "Invalid date format")
|
||||
|
||||
export const CreateHomeworkAssignmentSchema = z
|
||||
.object({
|
||||
sourceExamId: z.string().optional(),
|
||||
classId: z.string().min(1),
|
||||
title: z.string().min(1, "Title is required for quick assignments"),
|
||||
description: z.string().optional(),
|
||||
availableAt: dateStringSchema.optional(),
|
||||
dueAt: dateStringSchema.optional(),
|
||||
allowLate: z.coerce.boolean().optional(),
|
||||
lateDueAt: dateStringSchema.optional(),
|
||||
maxAttempts: z.coerce.number().int().min(1).max(20).optional(),
|
||||
targetStudentIds: z.array(z.string().min(1)).optional(),
|
||||
publish: z.coerce.boolean().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
// 时序校验:availableAt < dueAt < lateDueAt
|
||||
const available = data.availableAt ? new Date(data.availableAt).getTime() : null
|
||||
const due = data.dueAt ? new Date(data.dueAt).getTime() : null
|
||||
const lateDue = data.lateDueAt ? new Date(data.lateDueAt).getTime() : null
|
||||
|
||||
if (available !== null && due !== null && available > due) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["dueAt"],
|
||||
message: "截止时间必须晚于可用时间",
|
||||
})
|
||||
}
|
||||
if (due !== null && lateDue !== null && due > lateDue) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["lateDueAt"],
|
||||
message: "迟交截止时间必须晚于正常截止时间",
|
||||
})
|
||||
}
|
||||
if (data.allowLate && !data.lateDueAt) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["lateDueAt"],
|
||||
message: "允许迟交时必须设置迟交截止时间",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export type CreateHomeworkAssignmentInput = z.infer<typeof CreateHomeworkAssignmentSchema>
|
||||
|
||||
|
||||
@@ -115,36 +115,34 @@ export const getHomeworkAssignmentAnalytics = cache(
|
||||
|
||||
if (!assignment) return null
|
||||
|
||||
const [targetsRow] = await db
|
||||
.select({ c: count() })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(eq(homeworkAssignmentTargets.assignmentId, assignmentId))
|
||||
|
||||
const [submissionsRow] = await db
|
||||
.select({ c: count() })
|
||||
.from(homeworkSubmissions)
|
||||
.where(eq(homeworkSubmissions.assignmentId, assignmentId))
|
||||
|
||||
const [submittedRow] = await db
|
||||
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
||||
.from(homeworkSubmissions)
|
||||
.where(
|
||||
and(
|
||||
eq(homeworkSubmissions.assignmentId, assignmentId),
|
||||
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
||||
)
|
||||
)
|
||||
|
||||
const [gradedRow] = await db
|
||||
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
||||
.from(homeworkSubmissions)
|
||||
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.status, "graded")))
|
||||
|
||||
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
|
||||
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
|
||||
with: { question: true },
|
||||
orderBy: (q, { asc }) => [asc(q.order)],
|
||||
})
|
||||
const [targetsRows, submissionsRows, submittedRows, gradedRows, assignmentQuestions] = await Promise.all([
|
||||
db
|
||||
.select({ c: count() })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(eq(homeworkAssignmentTargets.assignmentId, assignmentId)),
|
||||
db
|
||||
.select({ c: count() })
|
||||
.from(homeworkSubmissions)
|
||||
.where(eq(homeworkSubmissions.assignmentId, assignmentId)),
|
||||
db
|
||||
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
||||
.from(homeworkSubmissions)
|
||||
.where(
|
||||
and(
|
||||
eq(homeworkSubmissions.assignmentId, assignmentId),
|
||||
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
||||
)
|
||||
),
|
||||
db
|
||||
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
||||
.from(homeworkSubmissions)
|
||||
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.status, "graded"))),
|
||||
db.query.homeworkAssignmentQuestions.findMany({
|
||||
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
|
||||
with: { question: true },
|
||||
orderBy: (q, { asc }) => [asc(q.order)],
|
||||
}),
|
||||
])
|
||||
|
||||
const statsByQuestionId = new Map<string, HomeworkAssignmentQuestionAnalytics>()
|
||||
|
||||
@@ -235,10 +233,10 @@ export const getHomeworkAssignmentAnalytics = cache(
|
||||
allowLate: assignment.allowLate,
|
||||
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
|
||||
maxAttempts: assignment.maxAttempts,
|
||||
targetCount: targetsRow?.c ?? 0,
|
||||
submissionCount: submissionsRow?.c ?? 0,
|
||||
submittedCount: submittedRow?.c ?? 0,
|
||||
gradedCount: gradedRow?.c ?? 0,
|
||||
targetCount: targetsRows[0]?.c ?? 0,
|
||||
submissionCount: submissionsRows[0]?.c ?? 0,
|
||||
submittedCount: submittedRows[0]?.c ?? 0,
|
||||
gradedCount: gradedRows[0]?.c ?? 0,
|
||||
createdAt: assignment.createdAt.toISOString(),
|
||||
updatedAt: assignment.updatedAt.toISOString(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user