完整性更新
现在已经实现了大部分基础功能
This commit is contained in:
@@ -75,8 +75,7 @@ export async function createExamAction(
|
||||
startTime: scheduled ? new Date(scheduled) : null,
|
||||
status: "draft",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to create exam:", error)
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
message: "Database error: Failed to create exam",
|
||||
@@ -156,8 +155,7 @@ export async function updateExamAction(
|
||||
await db.update(exams).set(updateData).where(eq(exams.id, examId))
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to update exam:", error)
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
message: "Database error: Failed to update exam",
|
||||
@@ -197,8 +195,7 @@ export async function deleteExamAction(
|
||||
|
||||
try {
|
||||
await db.delete(exams).where(eq(exams.id, examId))
|
||||
} catch (error) {
|
||||
console.error("Failed to delete exam:", error)
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
message: "Database error: Failed to delete exam",
|
||||
@@ -292,8 +289,7 @@ export async function duplicateExamAction(
|
||||
)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to duplicate exam:", error)
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
message: "Database error: Failed to duplicate exam",
|
||||
|
||||
184
src/modules/exams/components/exam-viewer.tsx
Normal file
184
src/modules/exams/components/exam-viewer.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, type ReactNode } from "react"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
type ChoiceOption = {
|
||||
id: string
|
||||
text: string
|
||||
}
|
||||
|
||||
type QuestionLike = {
|
||||
questionId: string
|
||||
questionType: string
|
||||
questionContent: unknown
|
||||
maxScore: number
|
||||
}
|
||||
|
||||
type ExamViewerProps = {
|
||||
structure: unknown
|
||||
questions: QuestionLike[]
|
||||
className?: string
|
||||
selectedQuestionId?: string | null
|
||||
onQuestionSelect?: (questionId: string) => void
|
||||
}
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
|
||||
|
||||
const getQuestionText = (content: unknown): string => {
|
||||
if (!isRecord(content)) return ""
|
||||
return typeof content.text === "string" ? content.text : ""
|
||||
}
|
||||
|
||||
const getOptions = (content: unknown): ChoiceOption[] => {
|
||||
if (!isRecord(content)) return []
|
||||
const raw = content.options
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: ChoiceOption[] = []
|
||||
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
|
||||
}
|
||||
|
||||
export function ExamViewer(props: ExamViewerProps) {
|
||||
const { structure, questions, className } = props
|
||||
const questionById = useMemo(() => new Map(questions.map((q) => [q.questionId, q] as const)), [questions])
|
||||
|
||||
const questionNumberById = useMemo(() => {
|
||||
const ids: string[] = []
|
||||
|
||||
const visit = (nodes: unknown) => {
|
||||
if (!Array.isArray(nodes)) return
|
||||
for (const node of nodes) {
|
||||
if (!isRecord(node)) continue
|
||||
if (node.type === "question") {
|
||||
const questionId = typeof node.questionId === "string" ? node.questionId : ""
|
||||
if (questionId) ids.push(questionId)
|
||||
continue
|
||||
}
|
||||
if (node.type === "group") {
|
||||
visit(Array.isArray(node.children) ? node.children : [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(structure) && structure.length > 0) {
|
||||
visit(structure)
|
||||
} else {
|
||||
for (const q of questions) ids.push(q.questionId)
|
||||
}
|
||||
|
||||
const out = new Map<string, number>()
|
||||
let n = 0
|
||||
for (const id of ids) {
|
||||
if (out.has(id)) continue
|
||||
n += 1
|
||||
out.set(id, n)
|
||||
}
|
||||
return out
|
||||
}, [structure, questions])
|
||||
|
||||
const renderNodes = (rawNodes: unknown, depth: number): ReactNode => {
|
||||
if (!Array.isArray(rawNodes)) return null
|
||||
|
||||
return (
|
||||
<div className={depth > 0 ? "space-y-4 pl-4 border-l" : "space-y-6"}>
|
||||
{rawNodes.map((node, idx) => {
|
||||
if (!isRecord(node)) return null
|
||||
const type = node.type
|
||||
|
||||
if (type === "group") {
|
||||
const title = typeof node.title === "string" && node.title.trim().length > 0 ? node.title : "Section"
|
||||
const children = Array.isArray(node.children) ? node.children : []
|
||||
return (
|
||||
<div key={`g-${depth}-${idx}`} className="space-y-3">
|
||||
<div className={depth === 0 ? "text-base font-semibold" : "text-sm font-semibold text-muted-foreground"}>
|
||||
{title}
|
||||
</div>
|
||||
{renderNodes(children, depth + 1)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === "question") {
|
||||
const questionId = typeof node.questionId === "string" ? node.questionId : ""
|
||||
if (!questionId) return null
|
||||
const questionNumber = questionNumberById.get(questionId) ?? 0
|
||||
const q = questionById.get(questionId) ?? null
|
||||
const text = getQuestionText(q?.questionContent ?? null)
|
||||
const options = getOptions(q?.questionContent ?? null)
|
||||
const scoreFromStructure = typeof node.score === "number" ? node.score : null
|
||||
const maxScore = scoreFromStructure ?? q?.maxScore ?? 0
|
||||
const isSelected = props.selectedQuestionId === questionId
|
||||
const isClickable = typeof props.onQuestionSelect === "function"
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`q-${questionId}`}
|
||||
className={cn(
|
||||
"rounded-md border bg-card p-4",
|
||||
isClickable && "cursor-pointer hover:bg-muted/30 transition-colors",
|
||||
isSelected && "ring-2 ring-primary/30"
|
||||
)}
|
||||
role={isClickable ? "button" : undefined}
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
onClick={isClickable ? () => props.onQuestionSelect?.(questionId) : undefined}
|
||||
onKeyDown={
|
||||
isClickable
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
props.onQuestionSelect?.(questionId)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="flex gap-3 min-w-0">
|
||||
<div className="font-semibold tabular-nums">{questionNumber > 0 ? `${questionNumber}.` : "—"}</div>
|
||||
<div className="min-w-0">
|
||||
<div className="whitespace-pre-wrap text-sm">{text || "—"}</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
<span className="capitalize">{q?.questionType ?? "unknown"}</span>
|
||||
<span className="mx-2">•</span>
|
||||
<span>Score: {maxScore}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(q?.questionType === "single_choice" || q?.questionType === "multiple_choice") && options.length > 0 ? (
|
||||
<div className="mt-4 grid grid-cols-1 gap-2 md:grid-cols-2">
|
||||
{options.map((opt) => (
|
||||
<div key={opt.id} className="flex gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium">{opt.id}.</span>
|
||||
<span className="whitespace-pre-wrap">{opt.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (Array.isArray(structure) && structure.length > 0) {
|
||||
return <div className={className}>{renderNodes(structure, 0)}</div>
|
||||
}
|
||||
|
||||
if (questions.length > 0) {
|
||||
const flatNodes = questions.map((q) => ({ type: "question", questionId: q.questionId, score: q.maxScore }))
|
||||
return <div className={className}>{renderNodes(flatNodes, 0)}</div>
|
||||
}
|
||||
|
||||
return <div className={className ?? "text-sm text-muted-foreground"}>No questions available.</div>
|
||||
}
|
||||
Reference in New Issue
Block a user