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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user