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:
SpecialX
2026-06-24 13:23:13 +08:00
parent 6114607c1e
commit d1e4ccbf98
7 changed files with 260 additions and 139 deletions

View File

@@ -1,18 +1,18 @@
import type { JSX } from "react"
import { getTranslations } from "next-intl/server"
import { ExamForm } from "@/modules/exams/components/exam-form"
export const dynamic = "force-dynamic"
export default function CreateExamPage(): JSX.Element {
export default async function CreateExamPage(): Promise<JSX.Element> {
const t = await getTranslations("examHomework")
return (
<div className="flex w-full justify-center items-center min-h-[calc(100vh-160px)] p-8 max-w-[1200px] mx-auto">
<div className="w-full space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Create Exam</h1>
<p className="text-muted-foreground">Configure a new exam for your classes.</p>
</div>
<ExamForm />
<div className="mx-auto w-full max-w-[1200px] space-y-6 p-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t("exam.form.createTitle")}</h1>
<p className="text-muted-foreground">{t("exam.form.createDescription")}</p>
</div>
<ExamForm />
</div>
)
}

View File

@@ -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>

View File

@@ -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({

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -32,7 +32,32 @@
"createFailed": "Failed to create exam",
"loadFormFailed": "Failed to load form data",
"loadSubjectsFailed": "Failed to load subjects",
"loadGradesFailed": "Failed to load grades"
"loadGradesFailed": "Failed to load grades",
"creating": "Creating...",
"detailsTitle": "Exam Details",
"detailsDesc": "Define the core information for your exam.",
"titlePlaceholder": "e.g. Midterm Mathematics Exam",
"subjectPlaceholder": "Select subject",
"gradePlaceholder": "Select grade level",
"difficultyPlaceholder": "Select level",
"difficultyLevel1": "Level 1 (Easy)",
"difficultyLevel2": "Level 2",
"difficultyLevel3": "Level 3 (Medium)",
"difficultyLevel4": "Level 4",
"difficultyLevel5": "Level 5 (Hard)",
"loadingSubjects": "Loading subjects...",
"loadingGrades": "Loading grades...",
"scheduledAtDesc": "If set, this exam will be scheduled for a specific time.",
"modeManual": "Manual Assembly",
"modeManualDesc": "Manually select questions from the bank and organize structure.",
"modeManualAction": "Create & Start Building",
"modeAi": "AI Generation",
"modeAiDesc": "Paste exam text and AI will auto-parse questions into structured preview.",
"modeAiAction": "Generate in Background",
"modeRich": "Rich Text Editor",
"modeRichDesc": "Use rich text editor to paste exam text, manually or AI-assisted marking of questions, groups, blanks, etc.",
"modeRichHint": "The rich text editor supports selecting text to mark as questions/groups/dotted chars/blanks, with live preview on the right.",
"modeRichAction": "Open Rich Text Editor"
},
"status": {
"draft": "Draft",

View File

@@ -32,7 +32,32 @@
"createFailed": "创建考试失败",
"loadFormFailed": "加载表单数据失败",
"loadSubjectsFailed": "加载科目失败",
"loadGradesFailed": "加载年级失败"
"loadGradesFailed": "加载年级失败",
"creating": "创建中...",
"detailsTitle": "基本信息",
"detailsDesc": "设置考试的核心信息。",
"titlePlaceholder": "例如:期中数学考试",
"subjectPlaceholder": "选择科目",
"gradePlaceholder": "选择年级",
"difficultyPlaceholder": "选择难度",
"difficultyLevel1": "1级 (简单)",
"difficultyLevel2": "2级",
"difficultyLevel3": "3级 (中等)",
"difficultyLevel4": "4级",
"difficultyLevel5": "5级 (困难)",
"loadingSubjects": "加载科目中...",
"loadingGrades": "加载年级中...",
"scheduledAtDesc": "设置后,考试将在指定时间开放。",
"modeManual": "手动组卷",
"modeManualDesc": "从题库手动选择题目并组织试卷结构。",
"modeManualAction": "创建并开始组卷",
"modeAi": "AI 生成",
"modeAiDesc": "粘贴试卷文本,AI 自动解析题目并生成结构化预览。",
"modeAiAction": "后台生成试卷",
"modeRich": "富文本编辑器",
"modeRichDesc": "使用富文本编辑器粘贴试卷文本,手动或 AI 辅助标记题目、分组、填空等。",
"modeRichHint": "富文本编辑器支持选中文本标记题目/分组/加点字/填空,右侧实时预览试卷效果。",
"modeRichAction": "打开富文本编辑器"
},
"status": {
"draft": "草稿",