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:
SpecialX
2026-06-23 17:38:56 +08:00
parent 1a9377222c
commit 4f0ef217a0
56 changed files with 1251 additions and 850 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
}
}
/**
* 格式化学生答案为可读字符串
*/

View File

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

View File

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