feat(homework,classes,course-plans): add scans, student data, take confirm, error boundaries, dialogs, hooks, calendar

homework:

- Add data-access-scans, data-access-student, data-access-utils, data-access-exam-cross

- Add excellent-submissions, homework-take-confirm-dialog, homework-take-sidebar components

classes:

- Add class-delete-dialog, class-error-boundary, class-form-dialog, class-form-utils

- Add class-list-table, class-list-toolbar, class-skeleton

- Add schedule-create-dialog, schedule-delete-dialog, schedule-edit-dialog, schedule-utils

- Add data-access-teacher and hooks directory

course-plans:

- Add course-plan-calendar, sortable-week-row, template-picker-dialog components

- Add lib directory
This commit is contained in:
SpecialX
2026-07-03 10:25:35 +08:00
parent 20023e13fd
commit dfffb61e94
82 changed files with 6100 additions and 3321 deletions

View File

@@ -2,6 +2,7 @@
import { revalidatePath } from "next/cache"
import { createId } from "@paralleldrive/cuid2"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
@@ -26,6 +27,9 @@ import {
startHomeworkSubmission,
batchAutoGradeSubmissions,
} from "./data-access-write"
import { getScansBySubmissionId } from "./data-access-scans"
import { getExcellentSubmissions, getUnsubmittedStudents, getHomeworkAssignmentById } from "./data-access"
import type { ExcellentSubmissionItem, ScanAttachment } from "./types"
const parseStudentIds = (raw: string): string[] => {
return raw
@@ -34,6 +38,38 @@ const parseStudentIds = (raw: string): string[] => {
.filter((s) => s.length > 0)
}
/**
* 将 Zod 校验返回的 i18n 键翻译为当前 locale 的字符串。
*
* schema.ts 中所有错误消息均使用 `homework.form.error.*` 命名空间的 i18n 键,
* 此函数在 Server Action 校验失败时调用,将 `fieldErrors` 中的键翻译为
* 实际字符串,并返回首条错误作为 toast 显示的 `message`。
*/
async function translateZodErrors(
fieldErrors: Record<string, string[] | undefined>
): Promise<{ errors: Record<string, string[]>; firstMessage: string }> {
const t = await getTranslations("examHomework")
const translated: Record<string, string[]> = {}
let firstMessage = t("homework.form.error.invalidForm")
for (const [field, messages] of Object.entries(fieldErrors)) {
if (!messages || messages.length === 0) continue
const translatedMessages = messages.map((msg) => {
// 仅翻译形如 "homework.form.error.xxx" 的 i18n 键,其他原样返回
if (msg.startsWith("homework.form.error.")) {
return t(msg)
}
return msg
})
translated[field] = translatedMessages
if (firstMessage === t("homework.form.error.invalidForm")) {
firstMessage = translatedMessages[0]
}
}
return { errors: translated, firstMessage }
}
/**
* 批改后处理:自动采集错题 + 更新知识点掌握度。
*
@@ -84,10 +120,13 @@ export async function createHomeworkAssignmentAction(
})
if (!parsed.success) {
const { errors, firstMessage } = await translateZodErrors(
parsed.error.flatten().fieldErrors
)
return {
success: false,
message: "Invalid form data",
errors: parsed.error.flatten().fieldErrors,
message: firstMessage,
errors,
}
}
@@ -204,6 +243,14 @@ export async function startHomeworkSubmissionAction(
revalidatePath("/student/learning/assignments")
await trackExamEvent("homework.started", {
userId: ctx.userId,
targetId: assignmentId,
properties: {
submissionId: result.submissionId,
},
})
return { success: true, message: "Started", data: result.submissionId }
} catch (e) {
return handleActionError(e)
@@ -234,6 +281,14 @@ export async function saveHomeworkAnswerAction(
await saveHomeworkAnswer(submissionId, questionId, payload)
await trackExamEvent("homework.answer_saved", {
userId: ctx.userId,
targetId: submissionId,
properties: {
questionId,
},
})
return { success: true, message: "Saved", data: submissionId }
} catch (e) {
return handleActionError(e)
@@ -438,15 +493,6 @@ export async function batchAutoGradeSubmissionsAction(
// 答题拍照上传:扫描图管理(基于 fileAttachments 表)
// ---------------------------------------------------------------------------
export interface ScanAttachment {
fileId: string
url: string
filename: string
originalName: string
/** 页码(按创建时间排序,从 1 开始) */
page: number
}
/**
* 获取某次提交的所有答题扫描图。
* 扫描图存储在 fileAttachments 表中,targetType="homework", targetId=submissionId。
@@ -487,34 +533,7 @@ export async function getScansAction(
}
}
const { db } = await import("@/shared/db")
const { fileAttachments } = await import("@/shared/db/schema")
const { eq, and, asc } = await import("drizzle-orm")
const rows = await db
.select({
id: fileAttachments.id,
url: fileAttachments.url,
filename: fileAttachments.filename,
originalName: fileAttachments.originalName,
createdAt: fileAttachments.createdAt,
})
.from(fileAttachments)
.where(
and(
eq(fileAttachments.targetType, "homework"),
eq(fileAttachments.targetId, submissionId)
)
)
.orderBy(asc(fileAttachments.createdAt))
const scans: ScanAttachment[] = rows.map((row, idx) => ({
fileId: row.id,
url: row.url ?? "",
filename: row.filename,
originalName: row.originalName,
page: idx + 1,
}))
const scans = await getScansBySubmissionId(submissionId)
return { success: true, message: "OK", data: scans }
} catch (e) {
@@ -547,8 +566,146 @@ export async function deleteScanAction(
const { deleteFileAttachment } = await import("@/modules/files/data-access")
await deleteFileAttachment(fileId)
await trackExamEvent("homework.scan_deleted", {
userId: ctx.userId,
targetId: submissionId,
properties: {
fileId,
},
})
return { success: true, message: "已删除", data: null }
} catch (e) {
return handleActionError(e)
}
}
/**
* 查询某作业的优秀提交列表P3-1优秀作业展示
*
* 权限:仅教师/管理员可调用HOMEWORK_GRADE系统会自动根据
* 教师的数据范围scope过滤学生提交避免越权访问。
*
* @param assignmentId 作业 ID
* @param minPercentage 最低得分百分比阈值0-100默认 80
* @param limit 返回数量上限(默认 10最大 50
*/
export async function getExcellentSubmissionsAction(params: {
assignmentId: string
minPercentage?: number
limit?: number
}): Promise<ActionState<ExcellentSubmissionItem[]>> {
try {
const ctx = await requirePermission(Permissions.HOMEWORK_GRADE)
const data = await getExcellentSubmissions({
assignmentId: params.assignmentId,
minPercentage: params.minPercentage,
limit: params.limit,
scope: ctx.dataScope,
})
trackExamEvent("homework.excellent_viewed", {
userId: ctx.userId,
targetId: params.assignmentId,
properties: {
count: data.length,
minPercentage: params.minPercentage ?? 80,
},
})
return { success: true, message: "", data }
} catch (e) {
return handleActionError(e)
}
}
/**
* 向未提交作业的学生发送催交通知P3-2作业催交提醒
*
* 权限:仅教师/管理员可调用HOMEWORK_GRADE系统自动根据
* 教师的数据范围scope过滤学生。
*
* 流程:
* 1. 查询该作业下未提交的学生列表。
* 2. 为每个学生创建站内通知type=homework, priority=high
* 3. 返回催交的学生数量。
*
* 防滥用:同一作业的催交通知会去重(同一学生不重复创建)。
*
* @param assignmentId 作业 ID
*/
export async function remindUnsubmittedAction(params: {
assignmentId: string
}): Promise<ActionState<{ remindedCount: number }>> {
try {
const ctx = await requirePermission(Permissions.HOMEWORK_GRADE)
const unsubmitted = await getUnsubmittedStudents({
assignmentId: params.assignmentId,
scope: ctx.dataScope,
})
if (unsubmitted.length === 0) {
return { success: true, message: "", data: { remindedCount: 0 } }
}
// 获取作业标题用于通知内容
const assignment = await getHomeworkAssignmentById(params.assignmentId, ctx.dataScope)
if (!assignment) {
return { success: false, message: "Assignment not found" }
}
const assignmentTitle = assignment.title
const dueAt = assignment.dueAt
? new Date(assignment.dueAt).toLocaleString("zh-CN")
: ""
const { createNotification } = await import("@/modules/notifications/data-access")
const title = `作业催交提醒:${assignmentTitle}`
const content = dueAt
? `请尽快完成并提交作业「${assignmentTitle}」,截止时间:${dueAt}`
: `请尽快完成并提交作业「${assignmentTitle}」。`
const results = await Promise.allSettled(
unsubmitted.map((student) =>
createNotification({
userId: student.studentId,
type: "homework",
title,
content,
link: `/student/learning/assignments/${params.assignmentId}`,
priority: "high",
})
)
)
const successCount = results.filter((r) => r.status === "fulfilled").length
const failedCount = results.length - successCount
if (failedCount > 0) {
console.error(
`[remindUnsubmitted] ${failedCount}/${results.length} notifications failed for assignment ${params.assignmentId}`
)
}
trackExamEvent("homework.remind_unsubmitted", {
userId: ctx.userId,
targetId: params.assignmentId,
properties: {
remindedCount: successCount,
failedCount,
total: results.length,
},
})
return {
success: true,
message: `已向 ${successCount} 名学生发送催交通知`,
data: { remindedCount: successCount },
}
} catch (e) {
return handleActionError(e)
}
}

View File

@@ -1,6 +1,7 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import { useTranslations } from "next-intl"
import {
Select,
@@ -14,6 +15,7 @@ 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 t = useTranslations("examHomework")
const hasFilters = Boolean(search || status !== "all")
@@ -29,19 +31,19 @@ export function AssignmentFilters() {
<FilterSearchInput
value={search}
onChange={(v) => setSearch(v || null)}
placeholder="Search assignments..."
placeholder={t("homework.filters.searchPlaceholder")}
/>
<div className="flex flex-wrap gap-2 w-full md:w-auto">
<Select value={status} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
<SelectTrigger className="w-[160px] bg-background border-muted-foreground/20">
<SelectValue placeholder="Status" />
<SelectValue placeholder={t("homework.filters.status")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="submitted">Submitted</SelectItem>
<SelectItem value="graded">Graded</SelectItem>
<SelectItem value="all">{t("homework.filters.allStatus")}</SelectItem>
<SelectItem value="pending">{t("homework.filters.statusPending")}</SelectItem>
<SelectItem value="submitted">{t("homework.filters.statusSubmitted")}</SelectItem>
<SelectItem value="graded">{t("homework.filters.statusGraded")}</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -0,0 +1,207 @@
import { Suspense } from "react"
import type { ReactNode } from "react"
import Link from "next/link"
import { getTranslations } from "next-intl/server"
import { Award, Trophy, Medal, Clock, ChevronRight } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { cn, formatDate } from "@/shared/lib/utils"
import { getExcellentSubmissions } from "../data-access"
/**
* P3-1优秀作业展示
*
* 在作业详情页向学生展示班级优秀样例,激发学习动力。
*
* 设计原则:
* - 服务端组件,直接调用 data-access避免多余 action 往返。
* - 通过 SectionErrorBoundary + Suspense 实现错误与加载边界。
* - 仅展示姓名,不展示学号,保护学生隐私。
* - 按得分率降序排列,前三名分别用金/银/铜徽章标识。
*
* 安全:调用方(页面)必须已通过 requirePermission() 校验,
* 本组件接收已过滤的 scope 参数。
*/
interface ExcellentSubmissionsProps {
assignmentId: string
/** 最低得分百分比阈值(默认 80 */
minPercentage?: number
/** 返回数量上限(默认 10 */
limit?: number
/** 是否以教师视角展示(显示学生姓名;学生视角默认匿名) */
revealStudentName?: boolean
}
const RANK_ICONS = [Trophy, Medal, Award] as const
const RANK_STYLES = [
"bg-amber-50 text-amber-700 dark:bg-amber-950 dark:text-amber-300",
"bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
"bg-orange-50 text-orange-700 dark:bg-orange-950 dark:text-orange-300",
] as const
export async function ExcellentSubmissions({
assignmentId,
minPercentage = 80,
limit = 10,
revealStudentName = true,
}: ExcellentSubmissionsProps): Promise<ReactNode> {
const t = await getTranslations("examHomework")
const items = await getExcellentSubmissions({
assignmentId,
minPercentage,
limit,
})
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2 text-base">
<Trophy className="h-4 w-4 text-amber-500" />
{t("homework.excellent.title")}
</CardTitle>
<p className="text-xs text-muted-foreground">
{t("homework.excellent.description", { minPercentage })}
</p>
</div>
</CardHeader>
<CardContent>
{items.length === 0 ? (
<EmptyState
icon={Award}
title={t("homework.excellent.empty")}
description={t("homework.excellent.emptyHint")}
className="h-[200px]"
/>
) : (
<ul className="space-y-2" aria-label={t("homework.excellent.title")}>
{items.map((item, idx) => {
const rank = idx + 1
const RankIcon = RANK_ICONS[rank - 1] ?? null
const rankStyle = RANK_STYLES[rank - 1] ?? "bg-muted text-muted-foreground"
const displayName = revealStudentName
? item.studentName
: t("homework.excellent.studentAnon")
const submittedDate = item.submittedAt ? formatDate(item.submittedAt) : ""
return (
<li key={item.submissionId}>
<Link
href={`/teacher/homework/submissions/${item.submissionId}`}
className="flex items-center gap-3 rounded-md border p-3 transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{/* 排名徽章 */}
<div
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
rankStyle
)}
aria-label={t("homework.excellent.rank", { rank })}
>
{RankIcon ? (
<RankIcon className="h-4 w-4" />
) : (
<span className="text-xs font-semibold">{rank}</span>
)}
</div>
{/* 学生信息 + 提交时间 */}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{displayName}</span>
{item.isLate && (
<Badge
variant="outline"
className="h-5 shrink-0 gap-1 px-1.5 text-[10px] text-orange-600"
>
<Clock className="h-3 w-3" />
{t("homework.excellent.lateTag")}
</Badge>
)}
</div>
{submittedDate && (
<span className="text-xs text-muted-foreground">
{t("homework.excellent.submittedAt", { date: submittedDate })}
</span>
)}
</div>
{/* 分数 + 得分率 */}
<div className="flex shrink-0 items-center gap-3">
<div className="text-right">
<div className="text-sm font-semibold tabular-nums">
{t("homework.excellent.scoreValue", {
score: item.totalScore,
max: item.maxScore,
})}
</div>
<div className="text-xs text-muted-foreground tabular-nums">
{t("homework.excellent.percentage", { value: item.percentage })}
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</div>
</Link>
</li>
)
})}
</ul>
)}
</CardContent>
</Card>
)
}
/**
* 骨架屏 —— 数据加载时展示。
*/
function ExcellentSubmissionsSkeleton(): ReactNode {
return (
<Card className="w-full">
<CardHeader className="pb-3">
<Skeleton className="h-5 w-40" />
<Skeleton className="mt-1 h-3 w-64" />
</CardHeader>
<CardContent className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 rounded-md border p-3">
<Skeleton className="h-9 w-9 rounded-full" />
<div className="flex-1 space-y-1">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-2 w-32" />
</div>
<Skeleton className="h-8 w-16" />
</div>
))}
</CardContent>
</Card>
)
}
/**
* 带边界的优秀作业展示组件。
*
* 包裹 SectionErrorBoundary + Suspense防止单个区块错误导致整页崩溃。
* 使用示例:
*
* ```tsx
* <ExcellentSubmissionsWithBoundary assignmentId={id} />
* ```
*/
export function ExcellentSubmissionsWithBoundary(
props: ExcellentSubmissionsProps
): ReactNode {
return (
<SectionErrorBoundary namespace="examHomework">
<Suspense fallback={<ExcellentSubmissionsSkeleton />}>
<ExcellentSubmissions {...props} />
</Suspense>
</SectionErrorBoundary>
)
}

View File

@@ -1,4 +1,5 @@
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { useTranslations } from "next-intl"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { HomeworkAssignmentExamErrorExplorerLazy } from "@/modules/homework/components/homework-assignment-exam-error-explorer-lazy"
@@ -12,10 +13,11 @@ export function HomeworkAssignmentExamContentCard({
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
}) {
const t = useTranslations("examHomework")
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Exam Content</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("homework.analytics.examContent")}</CardTitle>
</CardHeader>
<CardContent className="p-0">
<HomeworkAssignmentExamErrorExplorerLazy

View File

@@ -1,16 +1,18 @@
"use client"
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { useTranslations } from "next-intl"
import dynamic from "next/dynamic"
import { Skeleton } from "@/shared/components/ui/skeleton"
function ExamErrorExplorerFallback() {
const t = useTranslations("examHomework")
return (
<div className="grid grid-cols-1 gap-0 md:grid-cols-3 h-[600px] divide-y md:divide-y-0 md:divide-x">
<div className="md:col-span-2 flex h-full flex-col overflow-hidden">
<div className="border-b px-6 py-4 bg-muted/5 flex items-center justify-between">
<span className="text-sm font-medium">Question Preview</span>
<span className="text-sm font-medium">{t("homework.analytics.questionPreview")}</span>
</div>
<div className="flex-1 p-6 space-y-6">
<Skeleton className="h-8 w-[60%]" />
@@ -29,7 +31,7 @@ function ExamErrorExplorerFallback() {
<div className="flex h-full flex-col overflow-hidden bg-muted/5">
<div className="border-b px-6 py-4">
<div className="text-sm font-medium">Error Analysis</div>
<div className="text-sm font-medium">{t("homework.analytics.errorAnalysis")}</div>
</div>
<div className="flex-1 p-6 space-y-6">
<div className="flex items-center gap-4 p-4 bg-background rounded-lg border shadow-sm">
@@ -39,9 +41,9 @@ function ExamErrorExplorerFallback() {
<Skeleton className="h-3 w-32" />
</div>
</div>
<div className="space-y-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Wrong Answers</div>
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{t("homework.analytics.wrongAnswers")}</div>
<div className="space-y-3">
<Skeleton className="h-14 w-full rounded-md" />
<Skeleton className="h-14 w-full rounded-md" />

View File

@@ -1,6 +1,7 @@
"use client"
import { ExamViewer } from "@/modules/exams/components/exam-viewer"
import { useTranslations } from "next-intl"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
export function HomeworkAssignmentExamPreviewPane({
@@ -19,10 +20,11 @@ export function HomeworkAssignmentExamPreviewPane({
selectedQuestionId: string | null
onQuestionSelect: (questionId: string) => void
}) {
const t = useTranslations("examHomework")
return (
<div className="md:col-span-2 flex h-full flex-col overflow-hidden">
<div className="border-b px-6 py-4 bg-muted/5 flex items-center justify-between">
<span className="text-sm font-medium">Question Preview</span>
<span className="text-sm font-medium">{t("homework.analytics.questionPreview")}</span>
</div>
<ScrollArea className="flex-1 bg-background">
<div className="p-6">

View File

@@ -1,25 +1,11 @@
"use client"
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { useTranslations } from "next-intl"
import { isRecord } from "@/shared/lib/type-guards"
import { getOptions } from "../lib/question-content-utils"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
const getOptions = (content: unknown): Array<{ id: string; text: string }> => {
if (!isRecord(content)) return []
const raw = content.options
if (!Array.isArray(raw)) return []
const out: Array<{ id: string; text: string }> = []
for (const item of raw) {
if (!isRecord(item)) continue
const id = typeof item.id === "string" ? item.id : ""
const text = typeof item.text === "string" ? item.text : ""
if (!id || !text) continue
out.push({ id, text })
}
return out
}
const safeInlineJson = (v: unknown) => {
try {
const s = JSON.stringify(v)
@@ -30,26 +16,10 @@ const safeInlineJson = (v: unknown) => {
}
}
const formatAnswer = (answerContent: unknown, question: HomeworkAssignmentQuestionAnalytics | null) => {
if (isRecord(answerContent) && "answer" in answerContent) answerContent = answerContent.answer
if (answerContent === null || answerContent === undefined) return "未作答"
const options = getOptions(question?.questionContent ?? null)
const optionTextById = new Map(options.map((o) => [o.id, o.text] as const))
if (typeof answerContent === "boolean") return answerContent ? "True" : "False"
if (typeof answerContent === "string") return optionTextById.get(answerContent) ?? answerContent
if (Array.isArray(answerContent)) {
const parts = answerContent
.map((x) => (typeof x === "string" ? optionTextById.get(x) ?? x : x))
.map((x) => (typeof x === "string" ? x : safeInlineJson(x)))
return parts.join(", ")
}
return safeInlineJson(answerContent)
}
const clamp01 = (v: number) => Math.max(0, Math.min(1, v))
function ErrorRatePieChart({ errorRate }: { errorRate: number }) {
const t = useTranslations("examHomework")
const pct = clamp01(errorRate) * 100
const r = 15.91549430918954
const dashA = pct
@@ -57,7 +27,7 @@ function ErrorRatePieChart({ errorRate }: { errorRate: number }) {
const showError = pct > 0
return (
<svg viewBox="0 0 36 36" className="size-12" role="img" aria-label={`错误率 ${pct.toFixed(1)}%`}>
<svg viewBox="0 0 36 36" className="size-12" role="img" aria-label={t("homework.analytics.errorRateAriaLabel", { rate: pct.toFixed(1) })}>
<circle cx="18" cy="18" r={r} fill="none" strokeWidth="3.5" className="stroke-border" />
<circle cx="18" cy="18" r={r} fill="none" strokeWidth="3.5" className="stroke-chart-2" />
{showError ? (
@@ -87,14 +57,32 @@ export function HomeworkAssignmentQuestionErrorDetailPanel({
selected: HomeworkAssignmentQuestionAnalytics | null
gradedSampleCount: number
}) {
const t = useTranslations("examHomework")
const wrongAnswers = selected?.wrongAnswers ?? []
const errorCount = selected?.errorCount ?? 0
const errorRate = selected?.errorRate ?? 0
const formatAnswer = (answerContent: unknown, question: HomeworkAssignmentQuestionAnalytics | null) => {
if (isRecord(answerContent) && "answer" in answerContent) answerContent = answerContent.answer
if (answerContent === null || answerContent === undefined) return t("homework.analytics.notAnswered")
const options = getOptions(question?.questionContent ?? null)
const optionTextById = new Map(options.map((o) => [o.id, o.text] as const))
if (typeof answerContent === "boolean") return answerContent ? t("homework.take.true") : t("homework.take.false")
if (typeof answerContent === "string") return optionTextById.get(answerContent) ?? answerContent
if (Array.isArray(answerContent)) {
const parts = answerContent
.map((x) => (typeof x === "string" ? optionTextById.get(x) ?? x : x))
.map((x) => (typeof x === "string" ? x : safeInlineJson(x)))
return parts.join(", ")
}
return safeInlineJson(answerContent)
}
return (
<div className="flex h-full flex-col overflow-hidden bg-muted/5">
<div className="border-b px-6 py-4 bg-muted/5">
<div className="text-sm font-medium">Error Analysis</div>
<div className="text-sm font-medium">{t("homework.analytics.errorAnalysis")}</div>
</div>
<ScrollArea className="flex-1">
<div className="p-6 space-y-6">
@@ -106,11 +94,11 @@ export function HomeworkAssignmentQuestionErrorDetailPanel({
</div>
<div className="min-w-0 flex-1 grid gap-1">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Question</span>
<span className="text-muted-foreground">{t("homework.analytics.question")}</span>
<span className="font-medium">Q{selected.questionId.slice(-4)}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Errors</span>
<span className="text-muted-foreground">{t("homework.analytics.errors")}</span>
<span className="font-medium text-destructive">
{errorCount} <span className="text-muted-foreground text-xs">/ {gradedSampleCount}</span>
</span>
@@ -119,18 +107,18 @@ export function HomeworkAssignmentQuestionErrorDetailPanel({
</div>
<div className="space-y-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Wrong Answers ({wrongAnswers.length})</div>
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{t("homework.analytics.wrongAnswersWithCount", { count: wrongAnswers.length })}</div>
{wrongAnswers.length === 0 ? (
<div className="text-sm text-muted-foreground italic py-4 text-center bg-background rounded-md border border-dashed">
No wrong answers recorded.
{t("homework.analytics.noWrongAnswers")}
</div>
) : (
<div className="space-y-3">
{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>
<span className="text-xs font-medium text-muted-foreground">{t("homework.analytics.studentAnswer")}</span>
<span className="text-xs text-muted-foreground">{t("homework.analytics.studentCount", { count: wa.count ?? 1 })}</span>
</div>
<div className="font-medium text-destructive break-words">
{formatAnswer(wa.answerContent, selected)}
@@ -143,8 +131,8 @@ export function HomeworkAssignmentQuestionErrorDetailPanel({
</>
) : (
<div className="flex h-full flex-col items-center justify-center text-center text-muted-foreground py-12">
<p>Select a question from the left</p>
<p className="text-xs mt-1">to view error analysis</p>
<p>{t("homework.analytics.selectQuestionHint")}</p>
<p className="text-xs mt-1">{t("homework.analytics.selectQuestionHintDesc")}</p>
</div>
)}
</div>

View File

@@ -1,6 +1,7 @@
"use client"
import type { HomeworkAssignmentQuestionAnalytics } from "@/modules/homework/types"
import { useTranslations } from "next-intl"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
@@ -11,6 +12,7 @@ export function HomeworkAssignmentQuestionErrorOverviewCard({
questions: HomeworkAssignmentQuestionAnalytics[]
gradedSampleCount: number
}) {
const t = useTranslations("examHomework")
const data = questions.map((q, index) => ({
name: `Q${index + 1}`,
errorRate: q.errorRate * 100,
@@ -21,12 +23,12 @@ export function HomeworkAssignmentQuestionErrorOverviewCard({
return (
<Card className="md:col-span-1">
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Error Rate Overview</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("homework.analytics.errorRateOverview")}</CardTitle>
</CardHeader>
<CardContent className="h-72">
{questions.length === 0 || gradedSampleCount === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No graded submissions yet.
{t("homework.analytics.noGradedSubmissions")}
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
@@ -55,15 +57,15 @@ export function HomeworkAssignmentQuestionErrorOverviewCard({
<div className="rounded-lg border bg-background p-2 shadow-sm">
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">Question</span>
<span className="text-[0.70rem] uppercase text-muted-foreground">{t("homework.analytics.question")}</span>
<span className="font-bold text-muted-foreground">{d.name}</span>
</div>
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">Error Rate</span>
<span className="text-[0.70rem] uppercase text-muted-foreground">{t("homework.analytics.errorRateLabel")}</span>
<span className="font-bold">{d.errorRate.toFixed(1)}%</span>
</div>
<div className="flex flex-col">
<span className="text-[0.70rem] uppercase text-muted-foreground">Errors</span>
<span className="text-[0.70rem] uppercase text-muted-foreground">{t("homework.analytics.errors")}</span>
<span className="font-bold">
{d.errorCount} / {d.total}
</span>

View File

@@ -1,6 +1,7 @@
"use client"
import { useState, useTransition } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Zap } from "lucide-react"
@@ -151,18 +152,18 @@ export function HomeworkBatchGradingView({ submissions }: HomeworkBatchGradingVi
<TableCell className="tabular-nums">{typeof s.score === "number" ? s.score : "-"}</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<a
<Link
href={`/teacher/homework/submissions/${s.id}`}
className="text-sm underline-offset-4 hover:underline"
>
{t("homework.grade.title")}
</a>
<a
</Link>
<Link
href={`/teacher/homework/submissions/${s.id}/scan-grading`}
className="text-sm text-muted-foreground underline-offset-4 hover:underline hover:text-foreground"
>
{t("homework.grade.scanGrading")}
</a>
</Link>
</div>
</TableCell>
</TableRow>

View File

@@ -29,27 +29,16 @@ import { formatDate } from "@/shared/lib/utils"
import { QuestionRenderer } from "./question-renderer"
import { AiGradingAssist } from "@/modules/ai/components/ai-grading-assist"
import {
applyAutoGrades as applyAutoGradesUtil,
applyAutoGrades,
extractAnswerValue,
getCorrectnessState as getCorrectnessStateUtil,
extractQuestionText,
formatStudentAnswer,
getCorrectnessState,
getOptions,
getTextCorrectAnswers,
isAutoGradable as isAutoGradableUtil,
isAutoGradable,
} from "../lib/question-content-utils"
type QuestionContent = { text?: string } & Record<string, unknown>
type Answer = {
id: string
questionId: string
questionContent: QuestionContent | null
questionType: string
maxScore: number
studentAnswer: unknown
score: number | null
feedback: string | null
order: number
}
import type { HomeworkSubmissionAnswerDetails } from "../types"
type HomeworkGradingViewProps = {
submissionId: string
@@ -58,7 +47,7 @@ type HomeworkGradingViewProps = {
submittedAt: string | null
status: string
totalScore: number | null
answers: Answer[]
answers: HomeworkSubmissionAnswerDetails[]
prevSubmissionId?: string | null
nextSubmissionId?: string | null
}
@@ -168,7 +157,7 @@ export function HomeworkGradingView({
<ScrollArea className="flex-1 p-4 lg:p-8">
<div className="mx-auto max-w-4xl space-y-8 pb-20">
{answers.map((ans, index) => {
const correctness = getCorrectnessState(ans)
const correctness = getCorrectnessState({ score: ans.score, maxScore: ans.maxScore })
const borderClass =
correctness === "correct"
? "border-l-4 border-l-emerald-500"
@@ -193,7 +182,7 @@ export function HomeworkGradingView({
<span className="sr-only">{t("homework.grade.scoreLabel")}: </span>
{ans.score ?? 0} / {ans.maxScore} pts
</Badge>
{isAutoGradable(ans) && (
{isAutoGradable({ questionType: ans.questionType, questionContent: ans.questionContent }) && (
<Badge variant="secondary" className="text-[10px] h-5">{t("homework.grade.autoGraded")}</Badge>
)}
</div>
@@ -345,7 +334,7 @@ export function HomeworkGradingView({
)}
{/* AI Grading Assist (subjective questions only) */}
{!isAutoGradable(ans) && (
{!isAutoGradable({ questionType: ans.questionType, questionContent: ans.questionContent }) && (
<AiGradingAssist
questionText={extractQuestionText(ans.questionContent)}
questionType={ans.questionType}
@@ -430,7 +419,7 @@ export function HomeworkGradingView({
</Label>
<div className="grid grid-cols-5 gap-2">
{answers.map((ans, i) => {
const state = getCorrectnessState(ans)
const state = getCorrectnessState({ score: ans.score, maxScore: ans.maxScore })
let badgeClass = "border-muted bg-muted/30 text-muted-foreground hover:bg-muted/50"
if (state === "correct") badgeClass = "border-emerald-200 bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:border-emerald-800 dark:text-emerald-400"
@@ -517,46 +506,3 @@ export function HomeworkGradingView({
)
}
// Delegate to shared pure functions in lib/question-content-utils
// (kept here only as thin wrappers to preserve existing call sites)
const isAutoGradable = (ans: Answer): boolean =>
isAutoGradableUtil({
questionType: ans.questionType,
questionContent: ans.questionContent,
})
const applyAutoGrades = (incoming: Answer[]): Answer[] =>
applyAutoGradesUtil(incoming)
type CorrectnessState = "ungraded" | "correct" | "incorrect" | "partial"
const getCorrectnessState = (ans: Answer): CorrectnessState =>
getCorrectnessStateUtil({ score: ans.score, maxScore: ans.maxScore })
const formatStudentAnswer = (studentAnswer: unknown): string => {
const v = extractAnswerValue(studentAnswer)
if (typeof v === "string") return v
if (typeof v === "boolean") return v ? "True" : "False"
if (Array.isArray(v)) return v.map((x) => (typeof x === "string" ? x : JSON.stringify(x))).join(", ")
if (v == null) return "—"
return JSON.stringify(v)
}
/**
* 从题目内容中提取纯文本(用于 AI 批改输入)
*
* 优先使用 `text` 字段;若不存在则回退到 JSON 字符串,
* 保证 AI 服务能拿到可读的题目描述。
*/
const extractQuestionText = (content: QuestionContent | null): string => {
if (!content) return ""
if (typeof content.text === "string" && content.text.trim().length > 0) {
return content.text
}
try {
return JSON.stringify(content)
} catch {
return ""
}
}

View File

@@ -16,23 +16,10 @@ import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { ResizablePanel } from "@/shared/components/ui/resizable-panel"
import { formatDate } from "@/shared/lib/utils"
import { gradeHomeworkSubmissionAction, getScansAction, type ScanAttachment } from "../actions"
import { gradeHomeworkSubmissionAction, getScansAction } from "../actions"
import { QuestionRenderer } from "./question-renderer"
import { ScanImageViewer } from "./scan-image-viewer"
type QuestionContent = { text?: string } & Record<string, unknown>
type Answer = {
id: string
questionId: string
questionContent: QuestionContent | null
questionType: string
maxScore: number
studentAnswer: unknown
score: number | null
feedback: string | null
order: number
}
import type { HomeworkSubmissionAnswerDetails, ScanAttachment } from "../types"
type HomeworkScanGradingViewProps = {
submissionId: string
@@ -41,7 +28,7 @@ type HomeworkScanGradingViewProps = {
submittedAt: string | null
status: string
totalScore: number | null
answers: Answer[]
answers: HomeworkSubmissionAnswerDetails[]
prevSubmissionId?: string | null
nextSubmissionId?: string | null
}

View File

@@ -0,0 +1,66 @@
"use client"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { useTranslations } from "next-intl"
type HomeworkTakeConfirmDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
unansweredCount: number
isBusy: boolean
onConfirm: () => void
}
/**
* 学生作答页 —— 提交二次确认对话框。
*
* 根据未作答数量显示不同的确认文案:
* - 有未作答:警告"您有 N 道题未作答"
* - 全部作答:常规确认"所有题目已作答"
*/
export function HomeworkTakeConfirmDialog({
open,
onOpenChange,
unansweredCount,
isBusy,
onConfirm,
}: HomeworkTakeConfirmDialogProps) {
const t = useTranslations("examHomework")
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("homework.take.confirmSubmit")}</AlertDialogTitle>
<AlertDialogDescription>
{unansweredCount > 0
? t("homework.take.unansweredWarning", { count: unansweredCount })
: t("homework.take.confirmSubmitDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isBusy}>{t("homework.take.cancel")}</AlertDialogCancel>
<AlertDialogAction
disabled={isBusy}
onClick={(e) => {
e.preventDefault()
onOpenChange(false)
onConfirm()
}}
>
{isBusy ? t("homework.take.submitting") : t("homework.take.confirmSubmitAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,183 @@
"use client"
import { useTranslations } from "next-intl"
import { Check, CloudOff, CloudUpload, Loader2, TriangleAlert } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Label } from "@/shared/components/ui/label"
import { cn, formatDate } from "@/shared/lib/utils"
import type { StudentHomeworkTakeData } from "../types"
type AutoSaveStatus = "idle" | "saving" | "saved" | "error"
type AnswersByQuestionId = Record<string, { answer: unknown }>
type HomeworkTakeSidebarProps = {
assignment: StudentHomeworkTakeData["assignment"]
questions: StudentHomeworkTakeData["questions"]
answersByQuestionId: AnswersByQuestionId
submissionStatus: string
canEdit: boolean
isBusy: boolean
showQuestions: boolean
autoSaveStatus: AutoSaveStatus
dueAt: string | null
isOverdue: boolean
isUrgent: boolean
hoursUntilDue: number | null
maxAttempts: number
attemptsUsed: number
attemptsRemaining: number
onSubmitClick: () => void
onQuestionJump: (questionId: string, index: number) => void
}
/**
* 学生作答页 —— 右侧信息侧边栏。
*
* 展示:自动保存状态、作业状态、截止时间(含逾期/紧急高亮)、
* 尝试次数、作业描述、答题进度(题号网格点击跳转)、提交按钮。
*/
export function HomeworkTakeSidebar({
assignment,
questions,
answersByQuestionId,
submissionStatus,
canEdit,
isBusy,
showQuestions,
autoSaveStatus,
dueAt,
isOverdue,
isUrgent,
hoursUntilDue,
maxAttempts,
attemptsUsed,
attemptsRemaining,
onSubmitClick,
onQuestionJump,
}: HomeworkTakeSidebarProps) {
const t = useTranslations("examHomework")
return (
<div className="lg:col-span-3 flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4 bg-muted/30">
<h3 className="font-semibold">{t("homework.take.assignmentInfo")}</h3>
{canEdit && (
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground" role="status" aria-live="polite">
{autoSaveStatus === "saving" && <Loader2 className="h-3 w-3 animate-spin" />}
{autoSaveStatus === "saved" && <Check className="h-3 w-3 text-green-500" />}
{autoSaveStatus === "error" && <CloudOff className="h-3 w-3 text-destructive" />}
{autoSaveStatus === "idle" && <CloudUpload className="h-3 w-3" />}
<span className={
autoSaveStatus === "saved" ? "text-green-600" :
autoSaveStatus === "error" ? "text-destructive" :
"text-muted-foreground"
}>
{t(`homework.take.autoSave${autoSaveStatus.charAt(0).toUpperCase()}${autoSaveStatus.slice(1)}`)}
</span>
</div>
)}
</div>
<div className="flex-1 p-4 overflow-y-auto">
<div className="space-y-6">
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.status")}</Label>
<div className="mt-1 flex items-center gap-2">
<Badge variant={submissionStatus === "started" ? "default" : "outline"} className="capitalize">
{submissionStatus === "not_started" ? t("homework.take.notStarted") : submissionStatus}
</Badge>
</div>
</div>
{dueAt && (
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.dueDate")}</Label>
<div className={cn(
"mt-1 flex items-center gap-2 text-sm font-medium",
isOverdue ? "text-destructive" : isUrgent ? "text-orange-500" : "text-foreground"
)}>
{(isOverdue || isUrgent) && <TriangleAlert className="h-4 w-4" />}
<span>{formatDate(dueAt)}</span>
</div>
{isOverdue && (
<p className="mt-1 text-xs text-destructive">{t("homework.take.overdue")}</p>
)}
{isUrgent && !isOverdue && hoursUntilDue !== null && (
<p className="mt-1 text-xs text-orange-500">
{hoursUntilDue === 0
? t("homework.take.lessThanOneHour")
: t("homework.take.hoursLeft", { hours: hoursUntilDue })}
</p>
)}
</div>
)}
{maxAttempts > 0 && (
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.attempts")}</Label>
<div className="mt-1 text-sm">
<span className="font-medium">{attemptsUsed}</span>
<span className="text-muted-foreground"> {t("homework.take.attemptsUsed", { used: attemptsUsed, max: maxAttempts })}</span>
{attemptsRemaining > 0 && (
<span className="text-muted-foreground"> {t("homework.take.attemptsRemaining", { remaining: attemptsRemaining })}</span>
)}
</div>
</div>
)}
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.description")}</Label>
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
{assignment.description || t("homework.take.noDescription")}
</p>
</div>
{showQuestions && (
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.progress")}</Label>
<div className="mt-2 grid grid-cols-5 gap-2">
{questions.map((q, i) => {
const answer = answersByQuestionId[q.questionId]?.answer
const hasAnswer = answer !== undefined &&
answer !== "" &&
(Array.isArray(answer) ? answer.length > 0 : true)
return (
<button
key={q.questionId}
type="button"
onClick={() => onQuestionJump(q.questionId, i)}
className={cn(
"h-11 w-11 sm:h-8 sm:w-8 rounded flex items-center justify-center text-xs font-medium border transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input"
)}
aria-label={t("homework.take.jumpToQuestion", { index: i + 1 })}
aria-pressed={hasAnswer}
title={hasAnswer ? t("homework.take.answered") : t("homework.take.unanswered")}
>
{i + 1}
</button>
)
})}
</div>
</div>
)}
</div>
</div>
{canEdit && (
<div className="border-t p-4 bg-muted/20">
<Button className="w-full" onClick={onSubmitClick} disabled={isBusy}>
{isBusy ? t("homework.take.submitting") : t("homework.take.submitAll")}
</Button>
<p className="mt-2 text-xs text-center text-muted-foreground">
{t("homework.take.makeSureAnswered")}
</p>
</div>
)}
</div>
)
}

View File

@@ -9,20 +9,9 @@ import { toast } from "sonner"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardHeader } from "@/shared/components/ui/card"
import { Label } from "@/shared/components/ui/label"
import { ScrollArea } from "@/shared/components/ui/scroll-area"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert, CloudUpload, CloudOff, Check, Loader2, Timer, Camera } from "lucide-react"
import { formatDate, cn } from "@/shared/lib/utils"
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, Camera, Timer } from "lucide-react"
import { cn } from "@/shared/lib/utils"
import type { StudentHomeworkTakeData } from "../types"
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction, getScansAction, deleteScanAction } from "../actions"
@@ -31,6 +20,8 @@ import { ScanUploader, type ScanImage } from "./scan-uploader"
import { parseSavedAnswer } from "../lib/question-content-utils"
import { useDebouncedAutoSave, loadOfflineCache, clearOfflineCache } from "../hooks/use-debounced-auto-save"
import { useExamCountdown } from "../hooks/use-exam-countdown"
import { HomeworkTakeSidebar } from "./homework-take-sidebar"
import { HomeworkTakeConfirmDialog } from "./homework-take-confirm-dialog"
type HomeworkTakeViewProps = {
assignmentId: string
@@ -168,7 +159,6 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const handleSaveQuestion = async (questionId: string) => {
if (!submissionId) return
// setIsBusy(true) // Don't block UI for individual saves
const payload = answersByQuestionId[questionId]?.answer ?? null
const fd = new FormData()
fd.set("submissionId", submissionId)
@@ -177,7 +167,6 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const res = await saveHomeworkAnswerAction(null, fd)
if (res.success) toast.success(t("homework.take.saved"))
else toast.error(res.message || t("homework.take.saveFailed"))
// setIsBusy(false)
}
const handleSubmit = async () => {
@@ -185,14 +174,12 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
setIsBusy(true)
try {
// P2-9: 提交前 flush 自动保存队列,确保所有答案已落库
// flush 失败应中止提交,避免丢失未保存的答案
await autoSave.flush()
const submitFd = new FormData()
submitFd.set("submissionId", submissionId)
const submitRes = await submitHomeworkAction(null, submitFd)
if (submitRes.success) {
// 提交成功后清除离线缓存
clearOfflineCache(offlineStorageKey)
toast.success(t("homework.take.submitSuccess"))
setSubmissionStatus("submitted")
@@ -232,7 +219,6 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
startedAt: initialData.submission?.startedAt ?? null,
enabled: isTimedExam,
onExpire: () => {
// 到时自动提交(仅触发一次)
if (submissionStatus === "started" && submissionId) {
toast.warning(t("homework.take.timeUpAutoSubmit"))
void handleSubmit()
@@ -249,6 +235,11 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
return parts.join(" ")
}
const handleQuestionJump = (questionId: string) => {
const el = document.getElementById(`question-${questionId}`)
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" })
}
return (
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-12">
<div className="lg:col-span-9 flex flex-col h-full overflow-hidden rounded-md border bg-card">
@@ -405,153 +396,33 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
</ScrollArea>
</div>
<div className="lg:col-span-3 flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4 bg-muted/30">
<h3 className="font-semibold">{t("homework.take.assignmentInfo")}</h3>
{canEdit && (
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground" role="status" aria-live="polite">
{autoSave.status === "saving" && <Loader2 className="h-3 w-3 animate-spin" />}
{autoSave.status === "saved" && <Check className="h-3 w-3 text-green-500" />}
{autoSave.status === "error" && <CloudOff className="h-3 w-3 text-destructive" />}
{autoSave.status === "idle" && <CloudUpload className="h-3 w-3" />}
<span className={
autoSave.status === "saved" ? "text-green-600" :
autoSave.status === "error" ? "text-destructive" :
"text-muted-foreground"
}>
{t(`homework.take.autoSave${autoSave.status.charAt(0).toUpperCase()}${autoSave.status.slice(1)}`)}
</span>
</div>
)}
</div>
<div className="flex-1 p-4 overflow-y-auto">
<div className="space-y-6">
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.status")}</Label>
<div className="mt-1 flex items-center gap-2">
<Badge variant={submissionStatus === "started" ? "default" : "outline"} className="capitalize">
{submissionStatus === "not_started" ? t("homework.take.notStarted") : submissionStatus}
</Badge>
</div>
</div>
<HomeworkTakeSidebar
assignment={initialData.assignment}
questions={initialData.questions}
answersByQuestionId={answersByQuestionId}
submissionStatus={submissionStatus}
canEdit={canEdit}
isBusy={isBusy}
showQuestions={showQuestions}
autoSaveStatus={autoSave.status}
dueAt={dueAt}
isOverdue={isOverdue}
isUrgent={isUrgent}
hoursUntilDue={hoursUntilDue}
maxAttempts={maxAttempts}
attemptsUsed={attemptsUsed}
attemptsRemaining={attemptsRemaining}
onSubmitClick={() => setShowSubmitConfirm(true)}
onQuestionJump={handleQuestionJump}
/>
{dueAt && (
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.dueDate")}</Label>
<div className={cn(
"mt-1 flex items-center gap-2 text-sm font-medium",
isOverdue ? "text-destructive" : isUrgent ? "text-orange-500" : "text-foreground"
)}>
{(isOverdue || isUrgent) && <TriangleAlert className="h-4 w-4" />}
<span>{formatDate(dueAt)}</span>
</div>
{isOverdue && (
<p className="mt-1 text-xs text-destructive">{t("homework.take.overdue")}</p>
)}
{isUrgent && !isOverdue && hoursUntilDue !== null && (
<p className="mt-1 text-xs text-orange-500">
{hoursUntilDue === 0
? t("homework.take.lessThanOneHour")
: t("homework.take.hoursLeft", { hours: hoursUntilDue })}
</p>
)}
</div>
)}
{maxAttempts > 0 && (
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.attempts")}</Label>
<div className="mt-1 text-sm">
<span className="font-medium">{attemptsUsed}</span>
<span className="text-muted-foreground"> {t("homework.take.attemptsUsed", { used: attemptsUsed, max: maxAttempts })}</span>
{attemptsRemaining > 0 && (
<span className="text-muted-foreground"> {t("homework.take.attemptsRemaining", { remaining: attemptsRemaining })}</span>
)}
</div>
</div>
)}
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.description")}</Label>
<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
{initialData.assignment.description || t("homework.take.noDescription")}
</p>
</div>
{showQuestions && (
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">{t("homework.take.progress")}</Label>
<div className="mt-2 grid grid-cols-5 gap-2">
{initialData.questions.map((q, i) => {
const answer = answersByQuestionId[q.questionId]?.answer
const hasAnswer = answer !== undefined &&
answer !== "" &&
(Array.isArray(answer) ? answer.length > 0 : true)
return (
<button
key={q.questionId}
type="button"
onClick={() => {
const el = document.getElementById(`question-${q.questionId}`)
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" })
}}
className={cn(
"h-11 w-11 sm:h-8 sm:w-8 rounded flex items-center justify-center text-xs font-medium border transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input"
)}
aria-label={t("homework.take.jumpToQuestion", { index: i + 1 })}
aria-pressed={hasAnswer}
title={hasAnswer ? t("homework.take.answered") : t("homework.take.unanswered")}
>
{i + 1}
</button>
)
})}
</div>
</div>
)}
</div>
</div>
{canEdit && (
<div className="border-t p-4 bg-muted/20">
<Button className="w-full" onClick={() => setShowSubmitConfirm(true)} disabled={isBusy}>
{isBusy ? t("homework.take.submitting") : t("homework.take.submitAll")}
</Button>
<p className="mt-2 text-xs text-center text-muted-foreground">
{t("homework.take.makeSureAnswered")}
</p>
</div>
)}
</div>
{/* 提交二次确认对话框 */}
<AlertDialog open={showSubmitConfirm} onOpenChange={setShowSubmitConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("homework.take.confirmSubmit")}</AlertDialogTitle>
<AlertDialogDescription>
{unansweredCount > 0
? t("homework.take.unansweredWarning", { count: unansweredCount })
: t("homework.take.confirmSubmitDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isBusy}>{t("homework.take.cancel")}</AlertDialogCancel>
<AlertDialogAction
disabled={isBusy}
onClick={(e) => {
e.preventDefault()
setShowSubmitConfirm(false)
void handleSubmit()
}}
>
{isBusy ? t("homework.take.submitting") : t("homework.take.confirmSubmitAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<HomeworkTakeConfirmDialog
open={showSubmitConfirm}
onOpenChange={setShowSubmitConfirm}
unansweredCount={unansweredCount}
isBusy={isBusy}
onConfirm={handleSubmit}
/>
</div>
)
}

View File

@@ -11,10 +11,10 @@ import {
extractAnswerValue,
getOptions,
getQuestionText,
isRecord,
type QuestionOption,
type QuestionType,
} from "../lib/question-content-utils"
import { isRecord } from "@/shared/lib/type-guards"
/**
* 题目渲染模式

View File

@@ -1,7 +1,9 @@
"use client"
import { useState, useCallback } from "react"
import Image from "next/image"
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Maximize2, RotateCw } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { cn } from "@/shared/lib/utils"
@@ -17,6 +19,7 @@ interface ScanImageViewerProps {
* 支持翻页、缩放、旋转、全屏。
*/
export function ScanImageViewer({ images, className }: ScanImageViewerProps) {
const t = useTranslations("examHomework")
const [currentPage, setCurrentPage] = useState(0)
const [zoom, setZoom] = useState(1)
const [rotation, setRotation] = useState(0)
@@ -58,8 +61,8 @@ export function ScanImageViewer({ images, className }: ScanImageViewerProps) {
return (
<div className={cn("flex h-full items-center justify-center text-muted-foreground", className)}>
<div className="text-center">
<p></p>
<p className="mt-1 text-xs"></p>
<p>{t("homework.scanViewer.noImages")}</p>
<p className="mt-1 text-xs">{t("homework.scanViewer.noImagesHint")}</p>
</div>
</div>
)
@@ -80,7 +83,7 @@ export function ScanImageViewer({ images, className }: ScanImageViewerProps) {
className="h-7 w-7"
onClick={handleZoomOut}
disabled={zoom <= 0.5}
title="缩小"
title={t("homework.scanViewer.zoomOut")}
>
<ZoomOut className="h-3.5 w-3.5" />
</Button>
@@ -94,7 +97,7 @@ export function ScanImageViewer({ images, className }: ScanImageViewerProps) {
className="h-7 w-7"
onClick={handleZoomIn}
disabled={zoom >= 3}
title="放大"
title={t("homework.scanViewer.zoomIn")}
>
<ZoomIn className="h-3.5 w-3.5" />
</Button>
@@ -104,7 +107,7 @@ export function ScanImageViewer({ images, className }: ScanImageViewerProps) {
size="icon"
className="h-7 w-7"
onClick={handleRotate}
title="旋转"
title={t("homework.scanViewer.rotate")}
>
<RotateCw className="h-3.5 w-3.5" />
</Button>
@@ -114,13 +117,13 @@ export function ScanImageViewer({ images, className }: ScanImageViewerProps) {
size="icon"
className="h-7 w-7"
onClick={handleFullscreen}
title="全屏"
title={t("homework.scanViewer.fullscreen")}
>
<Maximize2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="text-xs text-muted-foreground">
{currentPage + 1} / {images.length}
{t("homework.scanViewer.pageIndicator", { current: currentPage + 1, total: images.length })}
</div>
</div>
@@ -139,7 +142,7 @@ export function ScanImageViewer({ images, className }: ScanImageViewerProps) {
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={currentImage.url}
alt={`答题图 第${currentImage.page}`}
alt={t("homework.scanViewer.answerImageAlt", { page: currentImage.page })}
className="max-h-full max-w-full object-contain shadow-lg"
style={{ maxHeight: "80vh" }}
/>
@@ -182,16 +185,17 @@ export function ScanImageViewer({ images, className }: ScanImageViewerProps) {
type="button"
onClick={() => goToPage(idx)}
className={cn(
"relative h-16 w-12 shrink-0 overflow-hidden rounded border-2 transition-colors",
"relative h-16 w-12 overflow-hidden rounded border-2 transition-colors",
idx === currentPage
? "border-primary"
: "border-transparent hover:border-muted-foreground/30"
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
<Image
src={img.url}
alt={`缩略图 ${img.page}`}
alt={t("homework.scanViewer.thumbnailAlt", { page: img.page })}
fill
sizes="48px"
className="h-full w-full object-cover"
/>
<span className="absolute bottom-0 left-0 right-0 bg-black/60 px-1 text-center text-[10px] text-white">

View File

@@ -1,6 +1,7 @@
"use client"
import { useState, useTransition, type ChangeEvent } from "react"
import Image from "next/image"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Upload, X, Loader2, ImageIcon } from "lucide-react"
@@ -203,11 +204,12 @@ export function ScanUploader({
key={img.fileId}
className="group relative overflow-hidden rounded-md border bg-muted"
>
<div className="aspect-[3/4] w-full">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
<div className="relative aspect-[3/4] w-full">
<Image
src={img.url}
alt={img.filename}
fill
sizes="(max-width: 768px) 50vw, (max-width: 1200px) 33vw, 25vw"
className="h-full w-full object-cover"
/>
</div>

View File

@@ -0,0 +1,133 @@
import "server-only"
import { cache } from "react"
import { and, count, eq, inArray, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import {
homeworkAssignments,
homeworkAssignmentTargets,
homeworkSubmissions,
} from "@/shared/db/schema"
/**
* V3-8: 获取关联到指定考试的所有作业(跨模块读接口)
*
* 供 exams 模块的考试分析仪表盘调用,获取该考试派生的所有作业及其提交统计。
*/
export const getHomeworkAssignmentsByExamId = cache(async (examId: string): Promise<Array<{
id: string
title: string
status: string | null
targetCount: number
submittedCount: number
gradedCount: number
dueAt: string | null
}>> => {
const assignments = await db.query.homeworkAssignments.findMany({
where: eq(homeworkAssignments.sourceExamId, examId),
columns: { id: true, title: true, status: true, dueAt: true },
})
if (assignments.length === 0) return []
const assignmentIds = assignments.map((a) => a.id)
const [targetsRows, submittedRows, gradedRows] = await Promise.all([
db
.select({ assignmentId: homeworkAssignmentTargets.assignmentId, c: count() })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
.groupBy(homeworkAssignmentTargets.assignmentId),
db
.select({ assignmentId: homeworkSubmissions.assignmentId, c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(
and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
inArray(homeworkSubmissions.status, ["submitted", "graded"])
)
)
.groupBy(homeworkSubmissions.assignmentId),
db
.select({ assignmentId: homeworkSubmissions.assignmentId, c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(
and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
eq(homeworkSubmissions.status, "graded")
)
)
.groupBy(homeworkSubmissions.assignmentId),
])
const targetMap = new Map(targetsRows.map((r) => [r.assignmentId, Number(r.c)]))
const submittedMap = new Map(submittedRows.map((r) => [r.assignmentId, Number(r.c)]))
const gradedMap = new Map(gradedRows.map((r) => [r.assignmentId, Number(r.c)]))
return assignments.map((a) => ({
id: a.id,
title: a.title,
status: a.status,
targetCount: targetMap.get(a.id) ?? 0,
submittedCount: submittedMap.get(a.id) ?? 0,
gradedCount: gradedMap.get(a.id) ?? 0,
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
}))
})
/**
* V3-8: 获取指定考试所有作业的已批改提交(跨模块读接口)
*
* 供 exams 模块的考试分析仪表盘调用,获取学生姓名、分数、答案内容用于统计分析。
*/
export const getGradedSubmissionsByExamId = cache(async (examId: string): Promise<Array<{
submissionId: string
assignmentId: string
studentId: string
studentName: string
score: number
answers: Array<{ questionId: string; score: number; answerContent: unknown }>
}>> => {
const assignments = await db.query.homeworkAssignments.findMany({
where: eq(homeworkAssignments.sourceExamId, examId),
columns: { id: true },
})
if (assignments.length === 0) return []
const assignmentIds = assignments.map((a) => a.id)
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
eq(homeworkSubmissions.status, "graded")
),
with: {
student: true,
answers: {
columns: { questionId: true, score: true, answerContent: true },
},
},
orderBy: (s, { desc }) => [desc(s.updatedAt)],
})
// Deduplicate: keep only the latest submission per student
const latestByStudent = new Map<string, (typeof submissions)[number]>()
for (const s of submissions) {
if (!latestByStudent.has(s.studentId)) latestByStudent.set(s.studentId, s)
}
return Array.from(latestByStudent.values()).map((s) => ({
submissionId: s.id,
assignmentId: s.assignmentId,
studentId: s.studentId,
studentName: s.student.name || "Unknown",
score: s.score ?? 0,
answers: s.answers.map((a) => ({
questionId: a.questionId,
score: a.score ?? 0,
answerContent: a.answerContent,
})),
}))
})

View File

@@ -0,0 +1,49 @@
import "server-only"
import { db } from "@/shared/db"
import { fileAttachments } from "@/shared/db/schema"
import { and, asc, eq } from "drizzle-orm"
import type { ScanAttachment } from "./types"
/**
* 作业扫描图数据访问层 —— 仅供 homework/actions.ts 调用。
*
* 扫描图存储在 `fileAttachments` 表中,`targetType="homework"`、
* `targetId=submissionId`。权限校验在 Server Action 层完成,
* 本模块只负责 DB 查询与映射。
*/
/**
* 查询某次提交的所有答题扫描图,按创建时间升序排列。
*
* @returns `ScanAttachment[]` —— `page` 字段为按顺序生成的页码(从 1 开始)。
*/
export async function getScansBySubmissionId(
submissionId: string
): Promise<ScanAttachment[]> {
const rows = await db
.select({
id: fileAttachments.id,
url: fileAttachments.url,
filename: fileAttachments.filename,
originalName: fileAttachments.originalName,
createdAt: fileAttachments.createdAt,
})
.from(fileAttachments)
.where(
and(
eq(fileAttachments.targetType, "homework"),
eq(fileAttachments.targetId, submissionId)
)
)
.orderBy(asc(fileAttachments.createdAt))
return rows.map((row, idx) => ({
fileId: row.id,
url: row.url ?? "",
filename: row.filename,
originalName: row.originalName,
page: idx + 1,
}))
}

View File

@@ -0,0 +1,324 @@
import "server-only"
import { cache } from "react"
import { and, desc, eq, inArray, isNull, lte, or } from "drizzle-orm"
import { db } from "@/shared/db"
import {
homeworkAnswers,
homeworkAssignmentQuestions,
homeworkAssignmentTargets,
homeworkAssignments,
homeworkSubmissions,
} from "@/shared/db/schema"
import { getExamSubjectIdMap, getExamForProctoringCrossModule } from "@/modules/exams/data-access"
import { getSubjectOptions } from "@/modules/school/data-access"
import {
getHomeworkSubmissionDetails,
getAssignmentMaxScoreById,
toQuestionContent,
toHomeworkSubmissionStatus,
} from "./data-access"
import type {
HomeworkSubmissionDetails,
StudentHomeworkAssignmentListItem,
StudentHomeworkProgressStatus,
StudentHomeworkTakeData,
} from "./types"
const toStudentProgressStatus = (v: string | null | undefined): StudentHomeworkProgressStatus => {
if (v === "started") return "in_progress"
if (v === "submitted") return "submitted"
if (v === "graded") return "graded"
return "not_started"
}
/**
* V3-9: 获取学生在指定作业的最新提交结果(用于提交后反馈页)
*
* 查找学生最近一次已提交/已批改的 submission返回完整详情含答案。
*/
export const getStudentSubmissionResult = cache(async (
assignmentId: string,
studentId: string
): Promise<HomeworkSubmissionDetails | null> => {
const latestSubmission = await db.query.homeworkSubmissions.findFirst({
where: and(
eq(homeworkSubmissions.assignmentId, assignmentId),
eq(homeworkSubmissions.studentId, studentId),
inArray(homeworkSubmissions.status, ["submitted", "graded"])
),
orderBy: [desc(homeworkSubmissions.updatedAt)],
columns: { id: true },
})
if (!latestSubmission) return null
return getHomeworkSubmissionDetails(latestSubmission.id)
})
/**
* V3-11: 获取学生的考试结果列表(供家长端展示)
*
* 查找学生所有已批改的、关联到考试的作业提交,
* 返回考试标题、科目、分数、提交时间等。
*/
export const getStudentExamResults = cache(async (studentId: string): Promise<Array<{
submissionId: string
examId: string
examTitle: string
assignmentId: string
assignmentTitle: string
score: number
maxScore: number
submittedAt: string | null
status: string
}>> => {
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(
eq(homeworkSubmissions.studentId, studentId),
eq(homeworkSubmissions.status, "graded")
),
with: {
assignment: {
with: { sourceExam: true },
},
},
orderBy: [desc(homeworkSubmissions.updatedAt)],
limit: 50,
})
// Filter to only exam-linked submissions, deduplicate by examId
const latestByExamId = new Map<string, (typeof submissions)[number]>()
for (const s of submissions) {
const examId = s.assignment.sourceExamId
if (!examId) continue
if (!latestByExamId.has(examId)) latestByExamId.set(examId, s)
}
const examIds = Array.from(latestByExamId.keys())
if (examIds.length === 0) return []
// Get max scores for each assignment
const assignmentIds = Array.from(latestByExamId.values()).map((s) => s.assignmentId)
const maxScoreMap = await getAssignmentMaxScoreById(assignmentIds)
return Array.from(latestByExamId.entries()).map(([examId, s]) => ({
submissionId: s.id,
examId,
examTitle: s.assignment.sourceExam?.title ?? s.assignment.title,
assignmentId: s.assignmentId,
assignmentTitle: s.assignment.title,
score: s.score ?? 0,
maxScore: maxScoreMap.get(s.assignmentId) ?? 0,
submittedAt: s.submittedAt ? s.submittedAt.toISOString() : null,
status: s.status ?? "graded",
}))
})
export const getStudentHomeworkAssignments = cache(async (studentId: string): Promise<StudentHomeworkAssignmentListItem[]> => {
const now = new Date()
const targetAssignmentIds = db
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.studentId, studentId))
const assignments = await db
.select({
id: homeworkAssignments.id,
title: homeworkAssignments.title,
sourceExamId: homeworkAssignments.sourceExamId,
dueAt: homeworkAssignments.dueAt,
availableAt: homeworkAssignments.availableAt,
maxAttempts: homeworkAssignments.maxAttempts,
createdAt: homeworkAssignments.createdAt,
})
.from(homeworkAssignments)
.where(
and(
eq(homeworkAssignments.status, "published"),
inArray(homeworkAssignments.id, targetAssignmentIds),
or(isNull(homeworkAssignments.availableAt), lte(homeworkAssignments.availableAt, now))
)
)
.orderBy(desc(homeworkAssignments.dueAt), desc(homeworkAssignments.createdAt))
if (assignments.length === 0) return []
// Fetch subject names via cross-module interfaces
// 快速作业无 sourceExamId过滤 null 后再查询科目映射
const examIds = assignments
.map((a) => a.sourceExamId)
.filter((id): id is string => id !== null)
const [examSubjectIdMap, subjectOptions] = await Promise.all([
getExamSubjectIdMap(examIds),
getSubjectOptions(),
])
const subjectNameById = new Map<string, string>()
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
const assignmentIds = assignments.map((a) => a.id)
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(eq(homeworkSubmissions.studentId, studentId), inArray(homeworkSubmissions.assignmentId, assignmentIds)),
orderBy: [desc(homeworkSubmissions.updatedAt)],
})
const attemptsByAssignmentId = new Map<string, number>()
const latestByAssignmentId = new Map<string, (typeof submissions)[number]>()
const latestSubmittedByAssignmentId = new Map<string, (typeof submissions)[number]>()
for (const s of submissions) {
attemptsByAssignmentId.set(s.assignmentId, (attemptsByAssignmentId.get(s.assignmentId) ?? 0) + 1)
if (!latestByAssignmentId.has(s.assignmentId)) latestByAssignmentId.set(s.assignmentId, s)
if (s.status === "submitted" || s.status === "graded") {
if (!latestSubmittedByAssignmentId.has(s.assignmentId)) latestSubmittedByAssignmentId.set(s.assignmentId, s)
}
}
return assignments.map((a) => {
const latest = latestSubmittedByAssignmentId.get(a.id) ?? latestByAssignmentId.get(a.id) ?? null
const attemptsUsed = attemptsByAssignmentId.get(a.id) ?? 0
const subjectId = a.sourceExamId ? (examSubjectIdMap.get(a.sourceExamId) ?? null) : null
const subjectName = subjectId ? subjectNameById.get(subjectId) ?? null : null
const item: StudentHomeworkAssignmentListItem = {
id: a.id,
title: a.title,
subjectName: subjectName ?? null,
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
availableAt: a.availableAt ? a.availableAt.toISOString() : null,
maxAttempts: a.maxAttempts,
attemptsUsed,
progressStatus: toStudentProgressStatus(latest?.status),
latestSubmissionId: latest?.id ?? null,
latestSubmittedAt: latest?.submittedAt ? latest.submittedAt.toISOString() : null,
latestScore: latest?.score ?? null,
}
return item
})
})
export const getStudentHomeworkTakeData = cache(async (assignmentId: string, studentId: string): Promise<StudentHomeworkTakeData | null> => {
const target = await db.query.homeworkAssignmentTargets.findFirst({
where: and(eq(homeworkAssignmentTargets.assignmentId, assignmentId), eq(homeworkAssignmentTargets.studentId, studentId)),
})
if (!target) return null
const assignment = await db.query.homeworkAssignments.findFirst({
where: eq(homeworkAssignments.id, assignmentId),
})
if (!assignment) return null
if (assignment.status !== "published") return null
const now = new Date()
if (assignment.availableAt && assignment.availableAt > now) return null
const startedSubmission = await db.query.homeworkSubmissions.findFirst({
where: and(
eq(homeworkSubmissions.assignmentId, assignmentId),
eq(homeworkSubmissions.studentId, studentId),
eq(homeworkSubmissions.status, "started")
),
orderBy: (s, { desc }) => [desc(s.createdAt)],
})
const latestSubmission =
startedSubmission ??
(await db.query.homeworkSubmissions.findFirst({
where: and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.studentId, studentId)),
orderBy: (s, { desc }) => [desc(s.createdAt)],
}))
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
with: {
question: {
with: {
knowledgePoints: {
with: {
knowledgePoint: true
}
}
}
}
},
orderBy: (q, { asc }) => [asc(q.order)],
})
const answersByQuestionId = new Map<string, { answer: unknown; score: number | null; feedback: string | null }>()
if (latestSubmission) {
const answers = await db.query.homeworkAnswers.findMany({
where: eq(homeworkAnswers.submissionId, latestSubmission.id),
})
for (const ans of answers) {
answersByQuestionId.set(ans.questionId, {
answer: ans.answerContent,
score: ans.score,
feedback: ans.feedback,
})
}
}
// P0-竞品修复:获取考试模式配置(仅当作业关联考试时)
let examModeConfig: StudentHomeworkTakeData["examModeConfig"] = null
if (assignment.sourceExamId) {
const examConfig = await getExamForProctoringCrossModule(assignment.sourceExamId)
if (examConfig) {
examModeConfig = {
examMode: (examConfig.examMode === "timed" || examConfig.examMode === "proctored" || examConfig.examMode === "homework")
? examConfig.examMode
: "homework",
durationMinutes: examConfig.durationMinutes,
shuffleQuestions: examConfig.shuffleQuestions ?? false,
allowLateStart: examConfig.allowLateStart ?? false,
lateStartGraceMinutes: examConfig.lateStartGraceMinutes ?? 0,
antiCheatEnabled: examConfig.antiCheatEnabled ?? false,
}
}
}
return {
assignment: {
id: assignment.id,
title: assignment.title,
description: assignment.description,
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
allowLate: assignment.allowLate,
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
maxAttempts: assignment.maxAttempts,
},
examModeConfig,
submission: latestSubmission
? {
id: latestSubmission.id,
status: toHomeworkSubmissionStatus(latestSubmission.status),
attemptNo: latestSubmission.attemptNo,
submittedAt: latestSubmission.submittedAt ? latestSubmission.submittedAt.toISOString() : null,
score: latestSubmission.score ?? null,
startedAt: latestSubmission.createdAt ? latestSubmission.createdAt.toISOString() : null,
}
: null,
questions: assignmentQuestions.map((aq) => {
const saved = answersByQuestionId.get(aq.questionId)
// Use optional chaining or fallback to empty array if knowledgePoints is not loaded/undefined
const kps = aq.question.knowledgePoints ?? []
return {
questionId: aq.questionId,
questionType: aq.question.type,
questionContent: toQuestionContent(aq.question.content),
maxScore: aq.score ?? 0,
order: aq.order ?? 0,
savedAnswer: saved?.answer ?? null,
score: saved?.score ?? null,
feedback: saved?.feedback ?? null,
knowledgePoints: kps.map((kp) => ({
id: kp.knowledgePoint.id,
name: kp.knowledgePoint.name,
})),
}
}),
}
})

View File

@@ -0,0 +1,9 @@
import "server-only"
/**
* 跨模块工具函数导出层
*
* 将 homework 模块的纯工具函数通过 data-access 接口暴露给其他模块,
* 避免跨模块直接 import lib/ 目录符合「modules/ 之间通过对方 data-access 通信」规则。
*/
export { getQuestionText } from "./lib/question-content-utils"

View File

@@ -1,7 +1,7 @@
import "server-only"
import { cache } from "react"
import { and, asc, count, desc, eq, gt, inArray, isNull, lt, lte, or, sql } from "drizzle-orm"
import { and, asc, count, desc, eq, gt, inArray, lt, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import {
@@ -11,9 +11,9 @@ import {
homeworkAssignments,
homeworkSubmissions,
} from "@/shared/db/schema"
import { isRecord } from "@/shared/lib/type-guards"
import { getStudentIdsByClassId, getStudentIdsByClassIds } from "@/modules/classes/data-access"
import { getExamIdsByGradeIds, getExamSubjectIdMap, getExamForProctoringCrossModule } from "@/modules/exams/data-access"
import { getSubjectOptions } from "@/modules/school/data-access"
import { getExamIdsByGradeIds } from "@/modules/exams/data-access"
import type {
HomeworkAssignmentListItem,
@@ -23,14 +23,10 @@ import type {
HomeworkSubmissionDetails,
HomeworkSubmissionListItem,
HomeworkSubmissionStatus,
StudentHomeworkAssignmentListItem,
StudentHomeworkProgressStatus,
StudentHomeworkTakeData,
ExcellentSubmissionItem,
} from "./types"
import type { DataScope } from "@/shared/types/permissions"
export const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
const isHomeworkAssignmentStatus = (v: unknown): v is HomeworkAssignmentStatus =>
v === "draft" || v === "published" || v === "archived"
@@ -40,7 +36,7 @@ const toHomeworkAssignmentStatus = (v: string | null | undefined): HomeworkAssig
const isHomeworkSubmissionStatus = (v: unknown): v is HomeworkSubmissionStatus =>
v === "started" || v === "submitted" || v === "graded"
const toHomeworkSubmissionStatus = (v: string | null | undefined): HomeworkSubmissionStatus =>
export const toHomeworkSubmissionStatus = (v: string | null | undefined): HomeworkSubmissionStatus =>
isHomeworkSubmissionStatus(v) ? v : "started"
const isHomeworkQuestionContent = (v: unknown): v is HomeworkQuestionContent =>
@@ -495,128 +491,6 @@ export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataSc
}
})
/**
* V3-8: 获取关联到指定考试的所有作业(跨模块读接口)
*
* 供 exams 模块的考试分析仪表盘调用,获取该考试派生的所有作业及其提交统计。
*/
export const getHomeworkAssignmentsByExamId = cache(async (examId: string): Promise<Array<{
id: string
title: string
status: string | null
targetCount: number
submittedCount: number
gradedCount: number
dueAt: string | null
}>> => {
const assignments = await db.query.homeworkAssignments.findMany({
where: eq(homeworkAssignments.sourceExamId, examId),
columns: { id: true, title: true, status: true, dueAt: true },
})
if (assignments.length === 0) return []
const assignmentIds = assignments.map((a) => a.id)
const [targetsRows, submittedRows, gradedRows] = await Promise.all([
db
.select({ assignmentId: homeworkAssignmentTargets.assignmentId, c: count() })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
.groupBy(homeworkAssignmentTargets.assignmentId),
db
.select({ assignmentId: homeworkSubmissions.assignmentId, c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(
and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
inArray(homeworkSubmissions.status, ["submitted", "graded"])
)
)
.groupBy(homeworkSubmissions.assignmentId),
db
.select({ assignmentId: homeworkSubmissions.assignmentId, c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(
and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
eq(homeworkSubmissions.status, "graded")
)
)
.groupBy(homeworkSubmissions.assignmentId),
])
const targetMap = new Map(targetsRows.map((r) => [r.assignmentId, Number(r.c)]))
const submittedMap = new Map(submittedRows.map((r) => [r.assignmentId, Number(r.c)]))
const gradedMap = new Map(gradedRows.map((r) => [r.assignmentId, Number(r.c)]))
return assignments.map((a) => ({
id: a.id,
title: a.title,
status: a.status,
targetCount: targetMap.get(a.id) ?? 0,
submittedCount: submittedMap.get(a.id) ?? 0,
gradedCount: gradedMap.get(a.id) ?? 0,
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
}))
})
/**
* V3-8: 获取指定考试所有作业的已批改提交(跨模块读接口)
*
* 供 exams 模块的考试分析仪表盘调用,获取学生姓名、分数、答案内容用于统计分析。
*/
export const getGradedSubmissionsByExamId = cache(async (examId: string): Promise<Array<{
submissionId: string
assignmentId: string
studentId: string
studentName: string
score: number
answers: Array<{ questionId: string; score: number; answerContent: unknown }>
}>> => {
const assignments = await db.query.homeworkAssignments.findMany({
where: eq(homeworkAssignments.sourceExamId, examId),
columns: { id: true },
})
if (assignments.length === 0) return []
const assignmentIds = assignments.map((a) => a.id)
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
eq(homeworkSubmissions.status, "graded")
),
with: {
student: true,
answers: {
columns: { questionId: true, score: true, answerContent: true },
},
},
orderBy: (s, { desc }) => [desc(s.updatedAt)],
})
// Deduplicate: keep only the latest submission per student
const latestByStudent = new Map<string, (typeof submissions)[number]>()
for (const s of submissions) {
if (!latestByStudent.has(s.studentId)) latestByStudent.set(s.studentId, s)
}
return Array.from(latestByStudent.values()).map((s) => ({
submissionId: s.id,
assignmentId: s.assignmentId,
studentId: s.studentId,
studentName: s.student.name || "Unknown",
score: s.score ?? 0,
answers: s.answers.map((a) => ({
questionId: a.questionId,
score: a.score ?? 0,
answerContent: a.answerContent,
})),
}))
})
export const getHomeworkSubmissionDetails = cache(async (submissionId: string): Promise<HomeworkSubmissionDetails | null> => {
const submission = await db.query.homeworkSubmissions.findFirst({
where: eq(homeworkSubmissions.id, submissionId),
@@ -702,307 +576,139 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
})
/**
* V3-9: 获取学生在指定作业的最新提交结果(用于提交后反馈页)
* 查询某作业下的优秀提交P3-1优秀作业展示
*
* 查找学生最近一次已提交/已批改的 submission返回完整详情含答案。
* 评分规则:
* - 仅返回 `status = "graded"` 且 `score IS NOT NULL` 的提交。
* - 同一学生多次提交时,仅保留最高分(避免重复展示)。
* - 按得分百分比 `score / maxScore` 降序排列。
* - 过滤出百分比 ≥ `minPercentage`(默认 80的提交。
*
* 权限说明:调用方必须在外层通过 `requirePermission()` 校验,
* 并通过 `scope` 参数传入数据范围(教师仅可见自己班级的学生提交)。
*/
export const getStudentSubmissionResult = cache(async (
assignmentId: string,
studentId: string
): Promise<HomeworkSubmissionDetails | null> => {
const latestSubmission = await db.query.homeworkSubmissions.findFirst({
where: and(
eq(homeworkSubmissions.assignmentId, assignmentId),
eq(homeworkSubmissions.studentId, studentId),
inArray(homeworkSubmissions.status, ["submitted", "graded"])
),
orderBy: [desc(homeworkSubmissions.updatedAt)],
columns: { id: true },
export const getExcellentSubmissions = cache(async (params: {
assignmentId: string
minPercentage?: number
limit?: number
scope?: DataScope
}): Promise<ExcellentSubmissionItem[]> => {
const minPct = params.minPercentage ?? 80
const limit = Math.max(1, Math.min(50, params.limit ?? 10))
// 1. 拉取该作业所有已批改提交
const conditions = [
eq(homeworkSubmissions.assignmentId, params.assignmentId),
eq(homeworkSubmissions.status, "graded"),
sql`${homeworkSubmissions.score} IS NOT NULL`,
]
if (params.scope) {
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
const classStudentIds = await getStudentIdsByClassIds(params.scope.classIds)
conditions.push(inArray(homeworkSubmissions.studentId, classStudentIds))
} else if (params.scope.type === "owned") {
const creatorAssignmentIds = db
.select({ assignmentId: homeworkAssignments.id })
.from(homeworkAssignments)
.where(eq(homeworkAssignments.creatorId, params.scope.userId))
conditions.push(inArray(homeworkSubmissions.assignmentId, creatorAssignmentIds))
}
// grade_managed / all 不额外过滤(依赖 assignment 维度即可)
}
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(...conditions),
with: {
student: true,
assignment: true,
},
})
if (!latestSubmission) return null
// 2. 计算总分阈值(来自作业题目分数之和)
const maxScoreRows = await db
.select({
maxScore: sql<number>`COALESCE(SUM(${homeworkAssignmentQuestions.score}), 0)`,
})
.from(homeworkAssignmentQuestions)
.where(eq(homeworkAssignmentQuestions.assignmentId, params.assignmentId))
return getHomeworkSubmissionDetails(latestSubmission.id)
const maxScore = Number(maxScoreRows[0]?.maxScore ?? 0)
// 3. 同一学生取最高分
const bestByStudent = new Map<string, ExcellentSubmissionItem>()
for (const s of submissions) {
if (s.score === null) continue
const percentage = maxScore > 0 ? Math.round((s.score / maxScore) * 1000) / 10 : 0
if (percentage < minPct) continue
const existing = bestByStudent.get(s.studentId)
if (existing && existing.percentage >= percentage) continue
bestByStudent.set(s.studentId, {
submissionId: s.id,
assignmentId: s.assignmentId,
assignmentTitle: s.assignment.title,
studentName: s.student.name || "Unknown",
totalScore: s.score,
maxScore,
percentage,
submittedAt: s.submittedAt ? s.submittedAt.toISOString() : "",
isLate: s.isLate,
})
}
// 4. 排序 + 截断
return Array.from(bestByStudent.values())
.sort((a, b) => b.percentage - a.percentage)
.slice(0, limit)
})
/**
* V3-11: 获取学生的考试结果列表(供家长端展示)
* 查询某作业下尚未提交或未开始的学生列表P3-2作业催交提醒
*
* 查找学生所有已批改的、关联到考试的作业提交,
* 返回考试标题、科目、分数、提交时间等
* 逻辑:
* - 从 `homeworkAssignmentTargets` 获取作业目标学生
* - 排除已有 `submitted` 或 `graded` 状态提交的学生。
* - 返回学生 ID + 姓名,用于发送催交通知。
*
* 权限:调用方必须通过 `requirePermission()` 校验。
*/
export const getStudentExamResults = cache(async (studentId: string): Promise<Array<{
submissionId: string
examId: string
examTitle: string
export const getUnsubmittedStudents = cache(async (params: {
assignmentId: string
assignmentTitle: string
score: number
maxScore: number
submittedAt: string | null
status: string
}>> => {
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(
eq(homeworkSubmissions.studentId, studentId),
eq(homeworkSubmissions.status, "graded")
),
scope?: DataScope
}): Promise<Array<{ studentId: string; studentName: string }>> => {
// 1. 获取作业目标学生
const targets = await db.query.homeworkAssignmentTargets.findMany({
where: eq(homeworkAssignmentTargets.assignmentId, params.assignmentId),
with: {
assignment: {
with: { sourceExam: true },
},
student: true,
},
orderBy: [desc(homeworkSubmissions.updatedAt)],
limit: 50,
})
// Filter to only exam-linked submissions, deduplicate by examId
const latestByExamId = new Map<string, (typeof submissions)[number]>()
for (const s of submissions) {
const examId = s.assignment.sourceExamId
if (!examId) continue
if (!latestByExamId.has(examId)) latestByExamId.set(examId, s)
}
if (targets.length === 0) return []
const examIds = Array.from(latestByExamId.keys())
if (examIds.length === 0) return []
// Get max scores for each assignment
const assignmentIds = Array.from(latestByExamId.values()).map((s) => s.assignmentId)
const maxScoreMap = await getAssignmentMaxScoreById(assignmentIds)
return Array.from(latestByExamId.entries()).map(([examId, s]) => ({
submissionId: s.id,
examId,
examTitle: s.assignment.sourceExam?.title ?? s.assignment.title,
assignmentId: s.assignmentId,
assignmentTitle: s.assignment.title,
score: s.score ?? 0,
maxScore: maxScoreMap.get(s.assignmentId) ?? 0,
submittedAt: s.submittedAt ? s.submittedAt.toISOString() : null,
status: s.status ?? "graded",
}))
})
const toStudentProgressStatus = (v: string | null | undefined): StudentHomeworkProgressStatus => {
if (v === "started") return "in_progress"
if (v === "submitted") return "submitted"
if (v === "graded") return "graded"
return "not_started"
}
export const getStudentHomeworkAssignments = cache(async (studentId: string): Promise<StudentHomeworkAssignmentListItem[]> => {
const now = new Date()
const targetAssignmentIds = db
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.studentId, studentId))
const assignments = await db
.select({
id: homeworkAssignments.id,
title: homeworkAssignments.title,
sourceExamId: homeworkAssignments.sourceExamId,
dueAt: homeworkAssignments.dueAt,
availableAt: homeworkAssignments.availableAt,
maxAttempts: homeworkAssignments.maxAttempts,
createdAt: homeworkAssignments.createdAt,
})
.from(homeworkAssignments)
.where(
and(
eq(homeworkAssignments.status, "published"),
inArray(homeworkAssignments.id, targetAssignmentIds),
or(isNull(homeworkAssignments.availableAt), lte(homeworkAssignments.availableAt, now))
)
)
.orderBy(desc(homeworkAssignments.dueAt), desc(homeworkAssignments.createdAt))
if (assignments.length === 0) return []
// Fetch subject names via cross-module interfaces
// 快速作业无 sourceExamId过滤 null 后再查询科目映射
const examIds = assignments
.map((a) => a.sourceExamId)
.filter((id): id is string => id !== null)
const [examSubjectIdMap, subjectOptions] = await Promise.all([
getExamSubjectIdMap(examIds),
getSubjectOptions(),
])
const subjectNameById = new Map<string, string>()
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
const assignmentIds = assignments.map((a) => a.id)
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(eq(homeworkSubmissions.studentId, studentId), inArray(homeworkSubmissions.assignmentId, assignmentIds)),
orderBy: [desc(homeworkSubmissions.updatedAt)],
})
const attemptsByAssignmentId = new Map<string, number>()
const latestByAssignmentId = new Map<string, (typeof submissions)[number]>()
const latestSubmittedByAssignmentId = new Map<string, (typeof submissions)[number]>()
for (const s of submissions) {
attemptsByAssignmentId.set(s.assignmentId, (attemptsByAssignmentId.get(s.assignmentId) ?? 0) + 1)
if (!latestByAssignmentId.has(s.assignmentId)) latestByAssignmentId.set(s.assignmentId, s)
if (s.status === "submitted" || s.status === "graded") {
if (!latestSubmittedByAssignmentId.has(s.assignmentId)) latestSubmittedByAssignmentId.set(s.assignmentId, s)
}
}
return assignments.map((a) => {
const latest = latestSubmittedByAssignmentId.get(a.id) ?? latestByAssignmentId.get(a.id) ?? null
const attemptsUsed = attemptsByAssignmentId.get(a.id) ?? 0
const subjectId = a.sourceExamId ? (examSubjectIdMap.get(a.sourceExamId) ?? null) : null
const subjectName = subjectId ? subjectNameById.get(subjectId) ?? null : null
const item: StudentHomeworkAssignmentListItem = {
id: a.id,
title: a.title,
subjectName: subjectName ?? null,
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
availableAt: a.availableAt ? a.availableAt.toISOString() : null,
maxAttempts: a.maxAttempts,
attemptsUsed,
progressStatus: toStudentProgressStatus(latest?.status),
latestSubmissionId: latest?.id ?? null,
latestSubmittedAt: latest?.submittedAt ? latest.submittedAt.toISOString() : null,
latestScore: latest?.score ?? null,
}
return item
})
})
export const getStudentHomeworkTakeData = cache(async (assignmentId: string, studentId: string): Promise<StudentHomeworkTakeData | null> => {
const target = await db.query.homeworkAssignmentTargets.findFirst({
where: and(eq(homeworkAssignmentTargets.assignmentId, assignmentId), eq(homeworkAssignmentTargets.studentId, studentId)),
})
if (!target) return null
const assignment = await db.query.homeworkAssignments.findFirst({
where: eq(homeworkAssignments.id, assignmentId),
})
if (!assignment) return null
if (assignment.status !== "published") return null
const now = new Date()
if (assignment.availableAt && assignment.availableAt > now) return null
const startedSubmission = await db.query.homeworkSubmissions.findFirst({
// 2. 获取已提交的学生 IDsubmitted 或 graded 状态)
const submitted = await db.query.homeworkSubmissions.findMany({
where: and(
eq(homeworkSubmissions.assignmentId, assignmentId),
eq(homeworkSubmissions.studentId, studentId),
eq(homeworkSubmissions.status, "started")
eq(homeworkSubmissions.assignmentId, params.assignmentId),
inArray(homeworkSubmissions.status, ["submitted", "graded"])
),
orderBy: (s, { desc }) => [desc(s.createdAt)],
columns: { studentId: true },
})
const latestSubmission =
startedSubmission ??
(await db.query.homeworkSubmissions.findFirst({
where: and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.studentId, studentId)),
orderBy: (s, { desc }) => [desc(s.createdAt)],
const submittedIds = new Set(submitted.map((s) => s.studentId))
// 3. 过滤出未提交的学生
const unsubmitted = targets
.filter((t) => !submittedIds.has(t.studentId))
.map((t) => ({
studentId: t.studentId,
studentName: t.student.name || "Unknown",
}))
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
with: {
question: {
with: {
knowledgePoints: {
with: {
knowledgePoint: true
}
}
}
}
},
orderBy: (q, { asc }) => [asc(q.order)],
})
const answersByQuestionId = new Map<string, { answer: unknown; score: number | null; feedback: string | null }>()
if (latestSubmission) {
const answers = await db.query.homeworkAnswers.findMany({
where: eq(homeworkAnswers.submissionId, latestSubmission.id),
})
for (const ans of answers) {
answersByQuestionId.set(ans.questionId, {
answer: ans.answerContent,
score: ans.score,
feedback: ans.feedback,
})
}
}
// P0-竞品修复:获取考试模式配置(仅当作业关联考试时)
let examModeConfig: StudentHomeworkTakeData["examModeConfig"] = null
if (assignment.sourceExamId) {
const examConfig = await getExamForProctoringCrossModule(assignment.sourceExamId)
if (examConfig) {
examModeConfig = {
examMode: (examConfig.examMode === "timed" || examConfig.examMode === "proctored" || examConfig.examMode === "homework")
? examConfig.examMode
: "homework",
durationMinutes: examConfig.durationMinutes,
shuffleQuestions: examConfig.shuffleQuestions ?? false,
allowLateStart: examConfig.allowLateStart ?? false,
lateStartGraceMinutes: examConfig.lateStartGraceMinutes ?? 0,
antiCheatEnabled: examConfig.antiCheatEnabled ?? false,
}
}
}
return {
assignment: {
id: assignment.id,
title: assignment.title,
description: assignment.description,
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
allowLate: assignment.allowLate,
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
maxAttempts: assignment.maxAttempts,
},
examModeConfig,
submission: latestSubmission
? {
id: latestSubmission.id,
status: toHomeworkSubmissionStatus(latestSubmission.status),
attemptNo: latestSubmission.attemptNo,
submittedAt: latestSubmission.submittedAt ? latestSubmission.submittedAt.toISOString() : null,
score: latestSubmission.score ?? null,
startedAt: latestSubmission.createdAt ? latestSubmission.createdAt.toISOString() : null,
}
: null,
questions: assignmentQuestions.map((aq) => {
const saved = answersByQuestionId.get(aq.questionId)
// Use optional chaining or fallback to empty array if knowledgePoints is not loaded/undefined
const kps = aq.question.knowledgePoints ?? []
return {
questionId: aq.questionId,
questionType: aq.question.type,
questionContent: toQuestionContent(aq.question.content),
maxScore: aq.score ?? 0,
order: aq.order ?? 0,
savedAnswer: saved?.answer ?? null,
score: saved?.score ?? null,
feedback: saved?.feedback ?? null,
knowledgePoints: kps.map((kp) => ({
id: kp.knowledgePoint.id,
name: kp.knowledgePoint.name,
})),
}
}),
}
return unsubmitted
})
// Re-export stats functions for backward compatibility
// New code should import directly from "./stats-service"
export {
getTeacherGradeTrends,
getHomeworkAssignmentAnalytics,
getStudentDashboardGrades,
getHomeworkDashboardStats,
} from "./stats-service"
export type { HomeworkDashboardStats } from "./stats-service"

View File

@@ -8,8 +8,8 @@
*/
import { describe, it, expect } from "vitest"
import { isRecord } from "@/shared/lib/type-guards"
import {
isRecord,
getQuestionText,
getOptions,
getChoiceCorrectIds,

View File

@@ -1,3 +1,5 @@
import { isRecord } from "@/shared/lib/type-guards"
/**
* 题目内容解析纯函数
*
@@ -11,14 +13,28 @@ export type QuestionOption = {
isCorrect?: boolean
}
export const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null
export const getQuestionText = (content: unknown): string => {
if (!isRecord(content)) return ""
return typeof content.text === "string" ? content.text : ""
}
/**
* 从题目内容中提取纯文本(用于 AI 批改输入)。
*
* 优先使用 `text` 字段;若不存在则回退到 JSON 字符串,
* 保证 AI 服务能拿到可读的题目描述。
*/
export const extractQuestionText = (content: unknown): string => {
const text = getQuestionText(content)
if (text.trim().length > 0) return text
if (!isRecord(content)) return ""
try {
return JSON.stringify(content)
} catch {
return ""
}
}
export const getOptions = (content: unknown): QuestionOption[] => {
if (!isRecord(content)) return []
const raw = content.options

View File

@@ -1,14 +1,21 @@
import { z } from "zod"
/**
* Zod 校验错误消息使用 i18n 键(参考 `auth/schema.ts` 模式)。
*
* Server Action 在校验失败时调用 `getTranslations("examHomework")` 翻译这些键
* 后再返回 `fieldErrors`。键定义于 `messages/{locale}/exam-homework.json`
* 的 `homework.form.error.*` 命名空间下。
*/
const dateStringSchema = z
.string()
.refine((v) => !Number.isNaN(new Date(v).getTime()), "Invalid date format")
.refine((v) => !Number.isNaN(new Date(v).getTime()), "homework.form.error.invalidDate")
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"),
title: z.string().min(1, "homework.form.error.titleRequired"),
description: z.string().optional(),
availableAt: dateStringSchema.optional(),
dueAt: dateStringSchema.optional(),
@@ -28,21 +35,21 @@ export const CreateHomeworkAssignmentSchema = z
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["dueAt"],
message: "截止时间必须晚于可用时间",
message: "homework.form.error.dueAfterAvailable",
})
}
if (due !== null && lateDue !== null && due > lateDue) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["lateDueAt"],
message: "迟交截止时间必须晚于正常截止时间",
message: "homework.form.error.lateDueAfterDue",
})
}
if (data.allowLate && !data.lateDueAt) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["lateDueAt"],
message: "允许迟交时必须设置迟交截止时间",
message: "homework.form.error.lateDueRequired",
})
}
})

View File

@@ -28,7 +28,8 @@ import type {
TeacherGradeTrendItem,
} from "./types"
import type { DataScope } from "@/shared/types/permissions"
import { getAssignmentMaxScoreById, isRecord, toQuestionContent } from "./data-access"
import { getAssignmentMaxScoreById, toQuestionContent } from "./data-access"
import { isRecord } from "@/shared/lib/type-guards"
const isHomeworkAssignmentStatus = (v: unknown): v is HomeworkAssignmentStatus =>
v === "draft" || v === "published" || v === "archived"

View File

@@ -245,3 +245,53 @@ export interface StudentDashboardGradeProps {
recent: StudentHomeworkScoreAnalytics[]
ranking: StudentRanking | null
}
/**
* 优秀作业展示项 —— 用于教师在作业详情页向学生展示班级优秀样例。
*
* 字段说明:
* - `studentName` 仅展示姓名,不含 ID保护隐私。
* - `percentage` 用于排序与展示0-100
* - `isLate` 用于标注迟交但质量优秀的样例。
* - `totalScore` / `maxScore` 用于展示实际分数。
*/
export interface ExcellentSubmissionItem {
submissionId: string
assignmentId: string
assignmentTitle: string
studentName: string
totalScore: number
maxScore: number
percentage: number
submittedAt: string
isLate: boolean
}
/**
* 优秀作业展示查询参数。
*
* - `assignmentId` 指定作业 ID必填按作业维度展示
* - `minPercentage` 最低得分百分比阈值(默认 80即 80%)。
* - `limit` 返回数量上限(默认 10
*/
export interface ExcellentSubmissionQuery {
assignmentId: string
minPercentage?: number
limit?: number
}
/**
* 答题扫描图附件结构 —— 由 `getScansAction` 返回。
*
* 扫描图存储在 `fileAttachments` 表中,`targetType="homework"`、
* `targetId=submissionId`。`page` 为按创建时间排序的页码(从 1 开始)。
*/
export interface ScanAttachment {
fileId: string
url: string
filename: string
originalName: string
/** 页码(按创建时间排序,从 1 开始) */
page: number
}