refactor(exams): redesign exam creation page with 3-mode selector
- Replace cramped 3-column grid with vertical layout - Add 3 large selectable cards at top: Manual / AI / Rich Text Editor - Rich Text Editor mode redirects to /teacher/exams/new - Basic info form is now always visible (not hidden in AI mode) - Exam mode config always visible at bottom - Add "rich" to mode enum with validation bypass - Replace all hardcoded English/Chinese strings with i18n keys - Add 20+ new i18n keys to zh-CN and en (mode labels, descriptions, actions) - Clean up mixed-language UI text
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import type { Control } from "react-hook-form"
|
||||
import {
|
||||
Card,
|
||||
@@ -20,14 +21,6 @@ type ExamBasicInfoFormProps = {
|
||||
loadingGrades: boolean
|
||||
}
|
||||
|
||||
const DIFFICULTY_OPTIONS = [
|
||||
{ value: "1", label: "Level 1 (Easy)" },
|
||||
{ value: "2", label: "Level 2" },
|
||||
{ value: "3", label: "Level 3 (Medium)" },
|
||||
{ value: "4", label: "Level 4" },
|
||||
{ value: "5", label: "Level 5 (Hard)" },
|
||||
]
|
||||
|
||||
export function ExamBasicInfoForm({
|
||||
control,
|
||||
subjects,
|
||||
@@ -35,36 +28,46 @@ export function ExamBasicInfoForm({
|
||||
loadingSubjects,
|
||||
loadingGrades,
|
||||
}: ExamBasicInfoFormProps) {
|
||||
const t = useTranslations("examHomework")
|
||||
|
||||
const DIFFICULTY_OPTIONS = [
|
||||
{ value: "1", label: t("exam.form.difficultyLevel1") },
|
||||
{ value: "2", label: t("exam.form.difficultyLevel2") },
|
||||
{ value: "3", label: t("exam.form.difficultyLevel3") },
|
||||
{ value: "4", label: t("exam.form.difficultyLevel4") },
|
||||
{ value: "5", label: t("exam.form.difficultyLevel5") },
|
||||
]
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Exam Details</CardTitle>
|
||||
<CardTitle>{t("exam.form.detailsTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
Define the core information for your exam.
|
||||
{t("exam.form.detailsDesc")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6">
|
||||
<TextField
|
||||
control={control}
|
||||
name="title"
|
||||
label="Title"
|
||||
placeholder="e.g. Midterm Mathematics Exam"
|
||||
label={t("exam.form.title")}
|
||||
placeholder={t("exam.form.titlePlaceholder")}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<SelectField
|
||||
control={control}
|
||||
name="subject"
|
||||
label="Subject"
|
||||
placeholder={loadingSubjects ? "Loading subjects..." : "Select subject"}
|
||||
label={t("exam.form.subject")}
|
||||
placeholder={loadingSubjects ? t("exam.form.loadingSubjects") : t("exam.form.subjectPlaceholder")}
|
||||
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
|
||||
disabled={loadingSubjects}
|
||||
/>
|
||||
<SelectField
|
||||
control={control}
|
||||
name="grade"
|
||||
label="Grade Level"
|
||||
placeholder={loadingGrades ? "Loading grades..." : "Select grade level"}
|
||||
label={t("exam.form.grade")}
|
||||
placeholder={loadingGrades ? t("exam.form.loadingGrades") : t("exam.form.gradePlaceholder")}
|
||||
options={grades.map((g) => ({ value: g.id, label: g.name }))}
|
||||
disabled={loadingGrades}
|
||||
/>
|
||||
@@ -74,20 +77,20 @@ export function ExamBasicInfoForm({
|
||||
<SelectField
|
||||
control={control}
|
||||
name="difficulty"
|
||||
label="Difficulty"
|
||||
placeholder="Select level"
|
||||
label={t("exam.form.difficulty")}
|
||||
placeholder={t("exam.form.difficultyPlaceholder")}
|
||||
options={DIFFICULTY_OPTIONS}
|
||||
/>
|
||||
<TextField
|
||||
control={control}
|
||||
name="totalScore"
|
||||
label="Total Score"
|
||||
label={t("exam.form.totalScore")}
|
||||
type="number"
|
||||
/>
|
||||
<TextField
|
||||
control={control}
|
||||
name="durationMin"
|
||||
label="Duration (min)"
|
||||
label={t("exam.form.durationMin")}
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
@@ -95,9 +98,9 @@ export function ExamBasicInfoForm({
|
||||
<TextField
|
||||
control={control}
|
||||
name="scheduledAt"
|
||||
label="Schedule Start Time (Optional)"
|
||||
label={t("exam.form.scheduledAt")}
|
||||
type="datetime-local"
|
||||
description="If set, this exam will be scheduled for a specific time."
|
||||
description={t("exam.form.scheduledAtDesc")}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -11,7 +11,7 @@ export const formSchema = z.object({
|
||||
totalScore: z.coerce.number().min(1, "Total score must be at least 1.").optional(),
|
||||
durationMin: z.coerce.number().min(10, "Duration must be at least 10 minutes.").optional(),
|
||||
scheduledAt: z.string().optional(),
|
||||
mode: z.enum(["manual", "ai"]),
|
||||
mode: z.enum(["manual", "ai", "rich"]),
|
||||
aiSourceText: z.string().optional(),
|
||||
aiQuestionCount: z.coerce.number().min(1).max(200).optional(),
|
||||
aiProviderId: z.string().optional(),
|
||||
@@ -23,6 +23,9 @@ export const formSchema = z.object({
|
||||
lateStartGraceMinutes: z.coerce.number().int().min(0).default(0),
|
||||
antiCheatEnabled: z.boolean().default(false),
|
||||
}).superRefine((data, ctx) => {
|
||||
// 富文本模式不需要表单校验,直接跳转到 /teacher/exams/new
|
||||
if (data.mode === "rich") return
|
||||
|
||||
// 监考模式必须设置考试时长
|
||||
if (data.examMode === "proctored" && (data.durationMinutes === null || data.durationMinutes === undefined || data.durationMinutes < 1)) {
|
||||
ctx.addIssue({
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useTransition, useEffect, useState, type FormEvent } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useForm, type Resolver } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { toast } from "sonner"
|
||||
@@ -24,6 +25,7 @@ export type { ExamFormValues } from "./exam-form-types"
|
||||
|
||||
export function ExamForm() {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("examHomework")
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [subjects, setSubjects] = useState<{ id: string; name: string }[]>([])
|
||||
const [loadingSubjects, setLoadingSubjects] = useState(true)
|
||||
@@ -56,7 +58,7 @@ export function ExamForm() {
|
||||
form.setValue("subject", subjectsResult.data[0].id)
|
||||
}
|
||||
} else {
|
||||
toast.error("Failed to load subjects")
|
||||
toast.error(t("exam.form.loadSubjectsFailed"))
|
||||
}
|
||||
|
||||
if (gradesResult.success && gradesResult.data) {
|
||||
@@ -65,7 +67,7 @@ export function ExamForm() {
|
||||
form.setValue("grade", gradesResult.data[0].id)
|
||||
}
|
||||
} else {
|
||||
toast.error("Failed to load grades")
|
||||
toast.error(t("exam.form.loadGradesFailed"))
|
||||
}
|
||||
if (aiProvidersResult.success && aiProvidersResult.data) {
|
||||
setAiProviders(aiProvidersResult.data)
|
||||
@@ -79,7 +81,7 @@ export function ExamForm() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Failed to load form data")
|
||||
toast.error(t("exam.form.loadFormFailed"))
|
||||
} finally {
|
||||
setLoadingSubjects(false)
|
||||
setLoadingGrades(false)
|
||||
@@ -87,10 +89,11 @@ export function ExamForm() {
|
||||
}
|
||||
}
|
||||
void fetchMetadata()
|
||||
}, [form])
|
||||
}, [form, t])
|
||||
|
||||
const mode = form.watch("mode")
|
||||
const isAiMode = mode === "ai"
|
||||
const isRichMode = mode === "rich"
|
||||
const previewSubject = form.watch("subject")
|
||||
const previewGrade = form.watch("grade")
|
||||
const previewDuration = form.watch("durationMin") ?? 90
|
||||
@@ -98,6 +101,12 @@ export function ExamForm() {
|
||||
const previewTitleValue = form.watch("title")
|
||||
|
||||
function onSubmit(data: ExamFormValues) {
|
||||
// 富文本模式不通过表单提交,直接跳转
|
||||
if (data.mode === "rich") {
|
||||
router.push("/teacher/exams/new")
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedTitle = data.mode === "ai" && !data.title?.trim() ? "AI Exam" : data.title?.trim() ?? ""
|
||||
const resolvedSubject = data.subject?.trim() || subjects[0]?.id || ""
|
||||
const resolvedGrade = data.grade?.trim() || grades[0]?.id || ""
|
||||
@@ -106,13 +115,13 @@ export function ExamForm() {
|
||||
const resolvedDurationMin = typeof data.durationMin === "number" && data.durationMin > 0 ? data.durationMin : 90
|
||||
|
||||
if (data.mode === "ai" && (!resolvedSubject || !resolvedGrade)) {
|
||||
toast.error("Missing subject or grade configuration")
|
||||
toast.error(t("exam.form.missingSubjectOrGrade"))
|
||||
return
|
||||
}
|
||||
if (data.mode === "ai") {
|
||||
const signature = preview.buildPreviewSignature(data)
|
||||
if (!preview.previewSignature || signature !== preview.previewSignature || preview.previewNodes.length === 0) {
|
||||
toast.error("Please preview and confirm before creating")
|
||||
toast.error(t("exam.form.previewBeforeCreate"))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -153,17 +162,21 @@ export function ExamForm() {
|
||||
: await createExamAction(null, formData)
|
||||
|
||||
if (result.success && result.data) {
|
||||
toast.success("Exam draft created", {
|
||||
description: "Redirecting to exam builder...",
|
||||
toast.success(t("exam.form.createSuccess"), {
|
||||
description: t("exam.form.redirecting"),
|
||||
})
|
||||
router.push(`/teacher/exams/${result.data}/build`)
|
||||
} else {
|
||||
toast.error(result.message || "Failed to create exam")
|
||||
toast.error(result.message || t("exam.form.createFailed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleCreateClick = () => {
|
||||
if (isRichMode) {
|
||||
router.push("/teacher/exams/new")
|
||||
return
|
||||
}
|
||||
if (isAiMode) {
|
||||
preview.handleBackgroundPreview()
|
||||
return
|
||||
@@ -198,10 +211,20 @@ export function ExamForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid w-full grid-cols-3 gap-8">
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleFormSubmit} className="col-span-2 space-y-6">
|
||||
{!isAiMode && (
|
||||
<div className="space-y-6">
|
||||
{/* 顶部: 创建方式选择 */}
|
||||
<ExamModeSelector
|
||||
mode={mode}
|
||||
setMode={(value) => form.setValue("mode", value)}
|
||||
isPending={isPending}
|
||||
handleCreateClick={handleCreateClick}
|
||||
/>
|
||||
|
||||
{/* 富文本模式: 不显示下方表单 */}
|
||||
{isRichMode ? null : (
|
||||
<Form {...form}>
|
||||
<form onSubmit={handleFormSubmit} className="space-y-6">
|
||||
{/* 基本信息(始终可见) */}
|
||||
<ExamBasicInfoForm
|
||||
control={form.control}
|
||||
subjects={subjects}
|
||||
@@ -209,27 +232,31 @@ export function ExamForm() {
|
||||
loadingSubjects={loadingSubjects}
|
||||
loadingGrades={loadingGrades}
|
||||
/>
|
||||
)}
|
||||
{isAiMode && (
|
||||
<ExamAiGenerator
|
||||
form={form}
|
||||
control={form.control}
|
||||
aiProviders={aiProviders}
|
||||
setAiProviders={setAiProviders}
|
||||
loadingAiProviders={loadingAiProviders}
|
||||
handlePreview={preview.handlePreview}
|
||||
handleBackgroundPreview={preview.handleBackgroundPreview}
|
||||
previewLoading={preview.previewLoading}
|
||||
previewTasks={preview.previewTasks}
|
||||
handleOpenPreviewTask={preview.handleOpenPreviewTask}
|
||||
activePreviewTaskCount={activePreviewTaskCount}
|
||||
runningPreviewTaskCount={runningPreviewTaskCount}
|
||||
queuedPreviewTaskCount={queuedPreviewTaskCount}
|
||||
/>
|
||||
)}
|
||||
<ExamModeConfig />
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{/* AI 模式: 显示 AI 生成器 */}
|
||||
{isAiMode && (
|
||||
<ExamAiGenerator
|
||||
form={form}
|
||||
control={form.control}
|
||||
aiProviders={aiProviders}
|
||||
setAiProviders={setAiProviders}
|
||||
loadingAiProviders={loadingAiProviders}
|
||||
handlePreview={preview.handlePreview}
|
||||
handleBackgroundPreview={preview.handleBackgroundPreview}
|
||||
previewLoading={preview.previewLoading}
|
||||
previewTasks={preview.previewTasks}
|
||||
handleOpenPreviewTask={preview.handleOpenPreviewTask}
|
||||
activePreviewTaskCount={activePreviewTaskCount}
|
||||
runningPreviewTaskCount={runningPreviewTaskCount}
|
||||
queuedPreviewTaskCount={queuedPreviewTaskCount}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 考试模式配置(始终可见) */}
|
||||
<ExamModeConfig />
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<ExamPreviewDialog
|
||||
previewOpen={preview.previewOpen}
|
||||
@@ -254,15 +281,6 @@ export function ExamForm() {
|
||||
handleConfirmCreate={handleConfirmCreate}
|
||||
previewTitleValue={previewTitleValue}
|
||||
/>
|
||||
|
||||
<div className="col-span-1 space-y-6">
|
||||
<ExamModeSelector
|
||||
mode={mode}
|
||||
setMode={(value) => form.setValue("mode", value)}
|
||||
isPending={isPending}
|
||||
handleCreateClick={handleCreateClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import { Loader2, Sparkles, BookOpen } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Loader2, Sparkles, BookOpen, FileText, ArrowRight } from "lucide-react"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
|
||||
export type ExamCreationMode = "manual" | "ai" | "rich"
|
||||
|
||||
type ExamModeSelectorProps = {
|
||||
mode: "manual" | "ai"
|
||||
setMode: (mode: "manual" | "ai") => void
|
||||
mode: ExamCreationMode
|
||||
setMode: (mode: ExamCreationMode) => void
|
||||
isPending: boolean
|
||||
handleCreateClick: () => void
|
||||
}
|
||||
@@ -25,61 +21,112 @@ export function ExamModeSelector({
|
||||
isPending,
|
||||
handleCreateClick,
|
||||
}: ExamModeSelectorProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Assembly Mode</CardTitle>
|
||||
<CardDescription>
|
||||
Choose how to build the exam structure.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative flex cursor-pointer flex-col rounded-lg border p-4 shadow-sm outline-none transition-all hover:bg-accent hover:text-accent-foreground text-left",
|
||||
mode === "manual" ? "border-primary ring-1 ring-primary" : "border-border"
|
||||
)}
|
||||
onClick={() => setMode("manual")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium">Manual Assembly</span>
|
||||
</div>
|
||||
<span className="mt-1 text-xs text-muted-foreground">
|
||||
Manually select questions from the bank and organize structure.
|
||||
</span>
|
||||
</button>
|
||||
const router = useRouter()
|
||||
const t = useTranslations("examHomework")
|
||||
|
||||
<button
|
||||
const modes: Array<{
|
||||
key: ExamCreationMode
|
||||
icon: typeof BookOpen
|
||||
title: string
|
||||
description: string
|
||||
}> = [
|
||||
{
|
||||
key: "manual",
|
||||
icon: BookOpen,
|
||||
title: t("exam.form.modeManual"),
|
||||
description: t("exam.form.modeManualDesc"),
|
||||
},
|
||||
{
|
||||
key: "ai",
|
||||
icon: Sparkles,
|
||||
title: t("exam.form.modeAi"),
|
||||
description: t("exam.form.modeAiDesc"),
|
||||
},
|
||||
{
|
||||
key: "rich",
|
||||
icon: FileText,
|
||||
title: t("exam.form.modeRich"),
|
||||
description: t("exam.form.modeRichDesc"),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 顶部3个大卡片 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{modes.map((m) => {
|
||||
const Icon = m.icon
|
||||
const isActive = mode === m.key
|
||||
return (
|
||||
<button
|
||||
key={m.key}
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative flex cursor-pointer flex-col rounded-lg border-2 p-5 shadow-sm outline-none transition-all text-left",
|
||||
isActive
|
||||
? "border-primary bg-primary/5 ring-2 ring-primary/20"
|
||||
: "border-border hover:border-primary/50 hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => setMode(m.key)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-lg",
|
||||
isActive ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="font-semibold">{m.title}</span>
|
||||
</div>
|
||||
<span className="mt-2 text-sm text-muted-foreground leading-relaxed">
|
||||
{m.description}
|
||||
</span>
|
||||
{isActive && (
|
||||
<div className="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 富文本模式: 显示跳转按钮 */}
|
||||
{mode === "rich" && (
|
||||
<div className="rounded-lg border bg-muted/30 p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t("exam.form.modeRichHint")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
className={cn(
|
||||
"relative flex cursor-pointer flex-col rounded-lg border p-4 shadow-sm outline-none transition-all hover:bg-accent hover:text-accent-foreground text-left",
|
||||
mode === "ai" ? "border-primary ring-1 ring-primary" : "border-border"
|
||||
)}
|
||||
onClick={() => setMode("ai")}
|
||||
size="lg"
|
||||
onClick={() => router.push("/teacher/exams/new")}
|
||||
className="gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium">AI Generation</span>
|
||||
</div>
|
||||
<span className="mt-1 text-xs text-muted-foreground">
|
||||
Automatically generate a draft exam based on your input.
|
||||
</span>
|
||||
</button>
|
||||
<FileText className="h-4 w-4" />
|
||||
{t("exam.form.modeRichAction")}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="button" className="w-full" disabled={isPending} onClick={handleCreateClick}>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isPending
|
||||
? "Creating Draft..."
|
||||
: mode === "ai"
|
||||
? "后台生成试卷"
|
||||
: "Create & Start Building"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 手动/AI 模式: 显示创建按钮 */}
|
||||
{mode !== "rich" && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
disabled={isPending}
|
||||
onClick={handleCreateClick}
|
||||
className="gap-2 min-w-[200px]"
|
||||
>
|
||||
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{isPending
|
||||
? t("exam.form.creating")
|
||||
: mode === "ai"
|
||||
? t("exam.form.modeAiAction")
|
||||
: t("exam.form.modeManualAction")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user