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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
207
src/modules/homework/components/excellent-submissions.tsx
Normal file
207
src/modules/homework/components/excellent-submissions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
183
src/modules/homework/components/homework-take-sidebar.tsx
Normal file
183
src/modules/homework/components/homework-take-sidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
/**
|
||||
* 题目渲染模式
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
133
src/modules/homework/data-access-exam-cross.ts
Normal file
133
src/modules/homework/data-access-exam-cross.ts
Normal 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,
|
||||
})),
|
||||
}))
|
||||
})
|
||||
49
src/modules/homework/data-access-scans.ts
Normal file
49
src/modules/homework/data-access-scans.ts
Normal 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,
|
||||
}))
|
||||
}
|
||||
324
src/modules/homework/data-access-student.ts
Normal file
324
src/modules/homework/data-access-student.ts
Normal 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,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
9
src/modules/homework/data-access-utils.ts
Normal file
9
src/modules/homework/data-access-utils.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import "server-only"
|
||||
|
||||
/**
|
||||
* 跨模块工具函数导出层
|
||||
*
|
||||
* 将 homework 模块的纯工具函数通过 data-access 接口暴露给其他模块,
|
||||
* 避免跨模块直接 import lib/ 目录,符合「modules/ 之间通过对方 data-access 通信」规则。
|
||||
*/
|
||||
export { getQuestionText } from "./lib/question-content-utils"
|
||||
@@ -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. 获取已提交的学生 ID(submitted 或 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"
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { isRecord } from "@/shared/lib/type-guards"
|
||||
import {
|
||||
isRecord,
|
||||
getQuestionText,
|
||||
getOptions,
|
||||
getChoiceCorrectIds,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user