完整性更新
Some checks failed
CI / build-and-test (push) Failing after 3m50s
CI / deploy (push) Has been skipped

现在已经实现了大部分基础功能
This commit is contained in:
SpecialX
2026-01-08 11:14:03 +08:00
parent 0da2eac0b4
commit 57807def37
155 changed files with 26421 additions and 1036 deletions

View File

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

View 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>
}