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,18 +1,18 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
import { ExamForm } from "@/modules/exams/components/exam-form"
|
import { ExamForm } from "@/modules/exams/components/exam-form"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
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 (
|
return (
|
||||||
<div className="flex w-full justify-center items-center min-h-[calc(100vh-160px)] p-8 max-w-[1200px] mx-auto">
|
<div className="mx-auto w-full max-w-[1200px] space-y-6 p-6">
|
||||||
<div className="w-full space-y-6">
|
<div>
|
||||||
<div>
|
<h1 className="text-2xl font-bold tracking-tight">{t("exam.form.createTitle")}</h1>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Create Exam</h1>
|
<p className="text-muted-foreground">{t("exam.form.createDescription")}</p>
|
||||||
<p className="text-muted-foreground">Configure a new exam for your classes.</p>
|
|
||||||
</div>
|
|
||||||
<ExamForm />
|
|
||||||
</div>
|
</div>
|
||||||
|
<ExamForm />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import type { Control } from "react-hook-form"
|
import type { Control } from "react-hook-form"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -20,14 +21,6 @@ type ExamBasicInfoFormProps = {
|
|||||||
loadingGrades: boolean
|
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({
|
export function ExamBasicInfoForm({
|
||||||
control,
|
control,
|
||||||
subjects,
|
subjects,
|
||||||
@@ -35,36 +28,46 @@ export function ExamBasicInfoForm({
|
|||||||
loadingSubjects,
|
loadingSubjects,
|
||||||
loadingGrades,
|
loadingGrades,
|
||||||
}: ExamBasicInfoFormProps) {
|
}: 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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Exam Details</CardTitle>
|
<CardTitle>{t("exam.form.detailsTitle")}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Define the core information for your exam.
|
{t("exam.form.detailsDesc")}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-6">
|
<CardContent className="grid gap-6">
|
||||||
<TextField
|
<TextField
|
||||||
control={control}
|
control={control}
|
||||||
name="title"
|
name="title"
|
||||||
label="Title"
|
label={t("exam.form.title")}
|
||||||
placeholder="e.g. Midterm Mathematics Exam"
|
placeholder={t("exam.form.titlePlaceholder")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<SelectField
|
<SelectField
|
||||||
control={control}
|
control={control}
|
||||||
name="subject"
|
name="subject"
|
||||||
label="Subject"
|
label={t("exam.form.subject")}
|
||||||
placeholder={loadingSubjects ? "Loading subjects..." : "Select subject"}
|
placeholder={loadingSubjects ? t("exam.form.loadingSubjects") : t("exam.form.subjectPlaceholder")}
|
||||||
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
|
options={subjects.map((s) => ({ value: s.id, label: s.name }))}
|
||||||
disabled={loadingSubjects}
|
disabled={loadingSubjects}
|
||||||
/>
|
/>
|
||||||
<SelectField
|
<SelectField
|
||||||
control={control}
|
control={control}
|
||||||
name="grade"
|
name="grade"
|
||||||
label="Grade Level"
|
label={t("exam.form.grade")}
|
||||||
placeholder={loadingGrades ? "Loading grades..." : "Select grade level"}
|
placeholder={loadingGrades ? t("exam.form.loadingGrades") : t("exam.form.gradePlaceholder")}
|
||||||
options={grades.map((g) => ({ value: g.id, label: g.name }))}
|
options={grades.map((g) => ({ value: g.id, label: g.name }))}
|
||||||
disabled={loadingGrades}
|
disabled={loadingGrades}
|
||||||
/>
|
/>
|
||||||
@@ -74,20 +77,20 @@ export function ExamBasicInfoForm({
|
|||||||
<SelectField
|
<SelectField
|
||||||
control={control}
|
control={control}
|
||||||
name="difficulty"
|
name="difficulty"
|
||||||
label="Difficulty"
|
label={t("exam.form.difficulty")}
|
||||||
placeholder="Select level"
|
placeholder={t("exam.form.difficultyPlaceholder")}
|
||||||
options={DIFFICULTY_OPTIONS}
|
options={DIFFICULTY_OPTIONS}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
control={control}
|
control={control}
|
||||||
name="totalScore"
|
name="totalScore"
|
||||||
label="Total Score"
|
label={t("exam.form.totalScore")}
|
||||||
type="number"
|
type="number"
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
control={control}
|
control={control}
|
||||||
name="durationMin"
|
name="durationMin"
|
||||||
label="Duration (min)"
|
label={t("exam.form.durationMin")}
|
||||||
type="number"
|
type="number"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,9 +98,9 @@ export function ExamBasicInfoForm({
|
|||||||
<TextField
|
<TextField
|
||||||
control={control}
|
control={control}
|
||||||
name="scheduledAt"
|
name="scheduledAt"
|
||||||
label="Schedule Start Time (Optional)"
|
label={t("exam.form.scheduledAt")}
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
description="If set, this exam will be scheduled for a specific time."
|
description={t("exam.form.scheduledAtDesc")}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const formSchema = z.object({
|
|||||||
totalScore: z.coerce.number().min(1, "Total score must be at least 1.").optional(),
|
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(),
|
durationMin: z.coerce.number().min(10, "Duration must be at least 10 minutes.").optional(),
|
||||||
scheduledAt: z.string().optional(),
|
scheduledAt: z.string().optional(),
|
||||||
mode: z.enum(["manual", "ai"]),
|
mode: z.enum(["manual", "ai", "rich"]),
|
||||||
aiSourceText: z.string().optional(),
|
aiSourceText: z.string().optional(),
|
||||||
aiQuestionCount: z.coerce.number().min(1).max(200).optional(),
|
aiQuestionCount: z.coerce.number().min(1).max(200).optional(),
|
||||||
aiProviderId: z.string().optional(),
|
aiProviderId: z.string().optional(),
|
||||||
@@ -23,6 +23,9 @@ export const formSchema = z.object({
|
|||||||
lateStartGraceMinutes: z.coerce.number().int().min(0).default(0),
|
lateStartGraceMinutes: z.coerce.number().int().min(0).default(0),
|
||||||
antiCheatEnabled: z.boolean().default(false),
|
antiCheatEnabled: z.boolean().default(false),
|
||||||
}).superRefine((data, ctx) => {
|
}).superRefine((data, ctx) => {
|
||||||
|
// 富文本模式不需要表单校验,直接跳转到 /teacher/exams/new
|
||||||
|
if (data.mode === "rich") return
|
||||||
|
|
||||||
// 监考模式必须设置考试时长
|
// 监考模式必须设置考试时长
|
||||||
if (data.examMode === "proctored" && (data.durationMinutes === null || data.durationMinutes === undefined || data.durationMinutes < 1)) {
|
if (data.examMode === "proctored" && (data.durationMinutes === null || data.durationMinutes === undefined || data.durationMinutes < 1)) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useTransition, useEffect, useState, type FormEvent } from "react"
|
import { useTransition, useEffect, useState, type FormEvent } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { useForm, type Resolver } from "react-hook-form"
|
import { useForm, type Resolver } from "react-hook-form"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -24,6 +25,7 @@ export type { ExamFormValues } from "./exam-form-types"
|
|||||||
|
|
||||||
export function ExamForm() {
|
export function ExamForm() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const t = useTranslations("examHomework")
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
const [subjects, setSubjects] = useState<{ id: string; name: string }[]>([])
|
const [subjects, setSubjects] = useState<{ id: string; name: string }[]>([])
|
||||||
const [loadingSubjects, setLoadingSubjects] = useState(true)
|
const [loadingSubjects, setLoadingSubjects] = useState(true)
|
||||||
@@ -56,7 +58,7 @@ export function ExamForm() {
|
|||||||
form.setValue("subject", subjectsResult.data[0].id)
|
form.setValue("subject", subjectsResult.data[0].id)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error("Failed to load subjects")
|
toast.error(t("exam.form.loadSubjectsFailed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gradesResult.success && gradesResult.data) {
|
if (gradesResult.success && gradesResult.data) {
|
||||||
@@ -65,7 +67,7 @@ export function ExamForm() {
|
|||||||
form.setValue("grade", gradesResult.data[0].id)
|
form.setValue("grade", gradesResult.data[0].id)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error("Failed to load grades")
|
toast.error(t("exam.form.loadGradesFailed"))
|
||||||
}
|
}
|
||||||
if (aiProvidersResult.success && aiProvidersResult.data) {
|
if (aiProvidersResult.success && aiProvidersResult.data) {
|
||||||
setAiProviders(aiProvidersResult.data)
|
setAiProviders(aiProvidersResult.data)
|
||||||
@@ -79,7 +81,7 @@ export function ExamForm() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
toast.error("Failed to load form data")
|
toast.error(t("exam.form.loadFormFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingSubjects(false)
|
setLoadingSubjects(false)
|
||||||
setLoadingGrades(false)
|
setLoadingGrades(false)
|
||||||
@@ -87,10 +89,11 @@ export function ExamForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
void fetchMetadata()
|
void fetchMetadata()
|
||||||
}, [form])
|
}, [form, t])
|
||||||
|
|
||||||
const mode = form.watch("mode")
|
const mode = form.watch("mode")
|
||||||
const isAiMode = mode === "ai"
|
const isAiMode = mode === "ai"
|
||||||
|
const isRichMode = mode === "rich"
|
||||||
const previewSubject = form.watch("subject")
|
const previewSubject = form.watch("subject")
|
||||||
const previewGrade = form.watch("grade")
|
const previewGrade = form.watch("grade")
|
||||||
const previewDuration = form.watch("durationMin") ?? 90
|
const previewDuration = form.watch("durationMin") ?? 90
|
||||||
@@ -98,6 +101,12 @@ export function ExamForm() {
|
|||||||
const previewTitleValue = form.watch("title")
|
const previewTitleValue = form.watch("title")
|
||||||
|
|
||||||
function onSubmit(data: ExamFormValues) {
|
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 resolvedTitle = data.mode === "ai" && !data.title?.trim() ? "AI Exam" : data.title?.trim() ?? ""
|
||||||
const resolvedSubject = data.subject?.trim() || subjects[0]?.id || ""
|
const resolvedSubject = data.subject?.trim() || subjects[0]?.id || ""
|
||||||
const resolvedGrade = data.grade?.trim() || grades[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
|
const resolvedDurationMin = typeof data.durationMin === "number" && data.durationMin > 0 ? data.durationMin : 90
|
||||||
|
|
||||||
if (data.mode === "ai" && (!resolvedSubject || !resolvedGrade)) {
|
if (data.mode === "ai" && (!resolvedSubject || !resolvedGrade)) {
|
||||||
toast.error("Missing subject or grade configuration")
|
toast.error(t("exam.form.missingSubjectOrGrade"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (data.mode === "ai") {
|
if (data.mode === "ai") {
|
||||||
const signature = preview.buildPreviewSignature(data)
|
const signature = preview.buildPreviewSignature(data)
|
||||||
if (!preview.previewSignature || signature !== preview.previewSignature || preview.previewNodes.length === 0) {
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,17 +162,21 @@ export function ExamForm() {
|
|||||||
: await createExamAction(null, formData)
|
: await createExamAction(null, formData)
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
toast.success("Exam draft created", {
|
toast.success(t("exam.form.createSuccess"), {
|
||||||
description: "Redirecting to exam builder...",
|
description: t("exam.form.redirecting"),
|
||||||
})
|
})
|
||||||
router.push(`/teacher/exams/${result.data}/build`)
|
router.push(`/teacher/exams/${result.data}/build`)
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message || "Failed to create exam")
|
toast.error(result.message || t("exam.form.createFailed"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateClick = () => {
|
const handleCreateClick = () => {
|
||||||
|
if (isRichMode) {
|
||||||
|
router.push("/teacher/exams/new")
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isAiMode) {
|
if (isAiMode) {
|
||||||
preview.handleBackgroundPreview()
|
preview.handleBackgroundPreview()
|
||||||
return
|
return
|
||||||
@@ -198,10 +211,20 @@ export function ExamForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid w-full grid-cols-3 gap-8">
|
<div className="space-y-6">
|
||||||
<Form {...form}>
|
{/* 顶部: 创建方式选择 */}
|
||||||
<form onSubmit={handleFormSubmit} className="col-span-2 space-y-6">
|
<ExamModeSelector
|
||||||
{!isAiMode && (
|
mode={mode}
|
||||||
|
setMode={(value) => form.setValue("mode", value)}
|
||||||
|
isPending={isPending}
|
||||||
|
handleCreateClick={handleCreateClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 富文本模式: 不显示下方表单 */}
|
||||||
|
{isRichMode ? null : (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={handleFormSubmit} className="space-y-6">
|
||||||
|
{/* 基本信息(始终可见) */}
|
||||||
<ExamBasicInfoForm
|
<ExamBasicInfoForm
|
||||||
control={form.control}
|
control={form.control}
|
||||||
subjects={subjects}
|
subjects={subjects}
|
||||||
@@ -209,27 +232,31 @@ export function ExamForm() {
|
|||||||
loadingSubjects={loadingSubjects}
|
loadingSubjects={loadingSubjects}
|
||||||
loadingGrades={loadingGrades}
|
loadingGrades={loadingGrades}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{isAiMode && (
|
{/* AI 模式: 显示 AI 生成器 */}
|
||||||
<ExamAiGenerator
|
{isAiMode && (
|
||||||
form={form}
|
<ExamAiGenerator
|
||||||
control={form.control}
|
form={form}
|
||||||
aiProviders={aiProviders}
|
control={form.control}
|
||||||
setAiProviders={setAiProviders}
|
aiProviders={aiProviders}
|
||||||
loadingAiProviders={loadingAiProviders}
|
setAiProviders={setAiProviders}
|
||||||
handlePreview={preview.handlePreview}
|
loadingAiProviders={loadingAiProviders}
|
||||||
handleBackgroundPreview={preview.handleBackgroundPreview}
|
handlePreview={preview.handlePreview}
|
||||||
previewLoading={preview.previewLoading}
|
handleBackgroundPreview={preview.handleBackgroundPreview}
|
||||||
previewTasks={preview.previewTasks}
|
previewLoading={preview.previewLoading}
|
||||||
handleOpenPreviewTask={preview.handleOpenPreviewTask}
|
previewTasks={preview.previewTasks}
|
||||||
activePreviewTaskCount={activePreviewTaskCount}
|
handleOpenPreviewTask={preview.handleOpenPreviewTask}
|
||||||
runningPreviewTaskCount={runningPreviewTaskCount}
|
activePreviewTaskCount={activePreviewTaskCount}
|
||||||
queuedPreviewTaskCount={queuedPreviewTaskCount}
|
runningPreviewTaskCount={runningPreviewTaskCount}
|
||||||
/>
|
queuedPreviewTaskCount={queuedPreviewTaskCount}
|
||||||
)}
|
/>
|
||||||
<ExamModeConfig />
|
)}
|
||||||
</form>
|
|
||||||
</Form>
|
{/* 考试模式配置(始终可见) */}
|
||||||
|
<ExamModeConfig />
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
|
||||||
<ExamPreviewDialog
|
<ExamPreviewDialog
|
||||||
previewOpen={preview.previewOpen}
|
previewOpen={preview.previewOpen}
|
||||||
@@ -254,15 +281,6 @@ export function ExamForm() {
|
|||||||
handleConfirmCreate={handleConfirmCreate}
|
handleConfirmCreate={handleConfirmCreate}
|
||||||
previewTitleValue={previewTitleValue}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
"use client"
|
"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 { cn } from "@/shared/lib/utils"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import {
|
|
||||||
Card,
|
export type ExamCreationMode = "manual" | "ai" | "rich"
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/shared/components/ui/card"
|
|
||||||
|
|
||||||
type ExamModeSelectorProps = {
|
type ExamModeSelectorProps = {
|
||||||
mode: "manual" | "ai"
|
mode: ExamCreationMode
|
||||||
setMode: (mode: "manual" | "ai") => void
|
setMode: (mode: ExamCreationMode) => void
|
||||||
isPending: boolean
|
isPending: boolean
|
||||||
handleCreateClick: () => void
|
handleCreateClick: () => void
|
||||||
}
|
}
|
||||||
@@ -25,61 +21,112 @@ export function ExamModeSelector({
|
|||||||
isPending,
|
isPending,
|
||||||
handleCreateClick,
|
handleCreateClick,
|
||||||
}: ExamModeSelectorProps) {
|
}: ExamModeSelectorProps) {
|
||||||
return (
|
const router = useRouter()
|
||||||
<Card>
|
const t = useTranslations("examHomework")
|
||||||
<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>
|
|
||||||
|
|
||||||
<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"
|
type="button"
|
||||||
className={cn(
|
size="lg"
|
||||||
"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",
|
onClick={() => router.push("/teacher/exams/new")}
|
||||||
mode === "ai" ? "border-primary ring-1 ring-primary" : "border-border"
|
className="gap-2"
|
||||||
)}
|
|
||||||
onClick={() => setMode("ai")}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<FileText className="h-4 w-4" />
|
||||||
<Sparkles className="h-4 w-4 text-primary" />
|
{t("exam.form.modeRichAction")}
|
||||||
<span className="font-medium">AI Generation</span>
|
<ArrowRight className="h-4 w-4" />
|
||||||
</div>
|
</Button>
|
||||||
<span className="mt-1 text-xs text-muted-foreground">
|
|
||||||
Automatically generate a draft exam based on your input.
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
)}
|
||||||
<CardFooter>
|
|
||||||
<Button type="button" className="w-full" disabled={isPending} onClick={handleCreateClick}>
|
{/* 手动/AI 模式: 显示创建按钮 */}
|
||||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{mode !== "rich" && (
|
||||||
{isPending
|
<div className="flex justify-end">
|
||||||
? "Creating Draft..."
|
<Button
|
||||||
: mode === "ai"
|
type="button"
|
||||||
? "后台生成试卷"
|
size="lg"
|
||||||
: "Create & Start Building"}
|
disabled={isPending}
|
||||||
</Button>
|
onClick={handleCreateClick}
|
||||||
</CardFooter>
|
className="gap-2 min-w-[200px]"
|
||||||
</Card>
|
>
|
||||||
|
{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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,32 @@
|
|||||||
"createFailed": "Failed to create exam",
|
"createFailed": "Failed to create exam",
|
||||||
"loadFormFailed": "Failed to load form data",
|
"loadFormFailed": "Failed to load form data",
|
||||||
"loadSubjectsFailed": "Failed to load subjects",
|
"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": {
|
"status": {
|
||||||
"draft": "Draft",
|
"draft": "Draft",
|
||||||
|
|||||||
@@ -32,7 +32,32 @@
|
|||||||
"createFailed": "创建考试失败",
|
"createFailed": "创建考试失败",
|
||||||
"loadFormFailed": "加载表单数据失败",
|
"loadFormFailed": "加载表单数据失败",
|
||||||
"loadSubjectsFailed": "加载科目失败",
|
"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": {
|
"status": {
|
||||||
"draft": "草稿",
|
"draft": "草稿",
|
||||||
|
|||||||
Reference in New Issue
Block a user