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

homework:

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

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

classes:

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

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

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

- Add data-access-teacher and hooks directory

course-plans:

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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