sync-docs-and-fixes

This commit is contained in:
SpecialX
2026-03-03 17:32:26 +08:00
parent 538805bad0
commit eb08c0ab68
73 changed files with 2218 additions and 422 deletions

View File

@@ -25,6 +25,20 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
// Helper to flatten questions for continuous numbering
let questionCounter = 0
const parseContent = (raw: unknown): QuestionContent => {
if (raw && typeof raw === "object") return raw as QuestionContent
if (typeof raw === "string") {
try {
const parsed = JSON.parse(raw) as unknown
if (parsed && typeof parsed === "object") return parsed as QuestionContent
return { text: raw }
} catch {
return { text: raw }
}
}
return {}
}
const renderNode = (node: ExamNode, depth: number = 0) => {
if (node.type === 'group') {
return (
@@ -45,7 +59,7 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
if (node.type === 'question' && node.question) {
questionCounter++
const q = node.question
const content = q.content as QuestionContent
const content = parseContent(q.content)
return (
<div key={node.id} className="mb-6 break-inside-avoid">

View File

@@ -28,13 +28,26 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
<div className="space-y-3 pb-4">
{questions.map((q) => {
const added = isAdded(q.id)
const content = q.content as { text?: string }
const parsedContent = (() => {
if (q.content && typeof q.content === "object") return q.content as { text?: string }
if (typeof q.content === "string") {
try {
const parsed = JSON.parse(q.content) as unknown
if (parsed && typeof parsed === "object") return parsed as { text?: string }
return { text: q.content }
} catch {
return { text: q.content }
}
}
return { text: "" }
})()
const typeLabel = typeof q.type === "string" ? q.type.replace("_", " ") : "unknown"
return (
<Card key={q.id} className="p-3 flex gap-3 hover:bg-muted/50 transition-colors">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] uppercase">
{q.type.replace("_", " ")}
{typeLabel}
</Badge>
<Badge variant="secondary" className="text-[10px]">
Lvl {q.difficulty}
@@ -46,7 +59,7 @@ export function QuestionBankList({ questions, onAdd, isAdded, onLoadMore, hasMor
))}
</div>
<p className="text-sm line-clamp-2 text-muted-foreground">
{content.text || "No content preview"}
{parsedContent.text || "No content preview"}
</p>
</div>
<div className="flex items-center">

View File

@@ -118,7 +118,22 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
onMove: (dir: 'up' | 'down') => void
onScoreChange: (score: number) => void
}) {
const content = item.question?.content as { text?: string }
const rawContent = item.question?.content
const parsedContent = (() => {
if (rawContent && typeof rawContent === "object") return rawContent as { text?: string; options?: Array<{ id?: string; text?: string }> }
if (typeof rawContent === "string") {
try {
const parsed = JSON.parse(rawContent) as unknown
if (parsed && typeof parsed === "object") return parsed as { text?: string; options?: Array<{ id?: string; text?: string }> }
return { text: rawContent }
} catch {
return { text: rawContent }
}
}
return { text: "" }
})()
const options = Array.isArray(parsedContent.options) ? parsedContent.options : []
return (
<div className="group flex flex-col gap-3 rounded-md border p-3 bg-card hover:border-primary/50 transition-colors">
<div className="flex items-start justify-between gap-3">
@@ -127,7 +142,7 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
{index + 1}
</span>
<p className="text-sm line-clamp-2 pt-0.5">
{content?.text || "Question content"}
{parsedContent.text || "Question content"}
</p>
</div>
<Button
@@ -139,6 +154,16 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
<Trash2 className="h-4 w-4" />
</Button>
</div>
{(item.question?.type === "single_choice" || item.question?.type === "multiple_choice") && options.length > 0 && (
<div className="grid grid-cols-1 gap-1 pl-8 text-xs text-muted-foreground">
{options.map((opt, idx) => (
<div key={opt.id ?? idx} className="flex gap-2">
<span className="font-medium">{opt.id ?? String.fromCharCode(65 + idx)}.</span>
<span>{opt.text ?? ""}</span>
</div>
))}
</div>
)}
<div className="flex items-center justify-between pl-8">
<div className="flex items-center gap-1">

View File

@@ -82,7 +82,22 @@ function SortableItem({
opacity: isDragging ? 0.5 : 1,
}
const content = item.question?.content as { text?: string }
const rawContent = item.question?.content
const parsedContent = (() => {
if (rawContent && typeof rawContent === "object") return rawContent as { text?: string; options?: Array<{ id?: string; text?: string }> }
if (typeof rawContent === "string") {
try {
const parsed = JSON.parse(rawContent) as unknown
if (parsed && typeof parsed === "object") return parsed as { text?: string; options?: Array<{ id?: string; text?: string }> }
return { text: rawContent }
} catch {
return { text: rawContent }
}
}
return { text: "" }
})()
const options = Array.isArray(parsedContent.options) ? parsedContent.options : []
return (
<div ref={setNodeRef} style={style} className={cn("group flex flex-col gap-3 rounded-md border p-3 bg-card hover:border-primary/50 transition-colors", isDragging && "ring-2 ring-primary")}>
@@ -92,7 +107,7 @@ function SortableItem({
<GripVertical className="h-4 w-4" />
</button>
<p className="text-sm line-clamp-2 pt-0.5 select-none">
{content?.text || "Question content"}
{parsedContent.text || "Question content"}
</p>
</div>
<Button
@@ -104,6 +119,16 @@ function SortableItem({
<Trash2 className="h-4 w-4" />
</Button>
</div>
{(item.question?.type === "single_choice" || item.question?.type === "multiple_choice") && options.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-1 pl-8 text-xs text-muted-foreground">
{options.map((opt, idx) => (
<div key={opt.id ?? idx} className="flex gap-2">
<span className="font-medium">{opt.id ?? String.fromCharCode(65 + idx)}.</span>
<span>{opt.text ?? ""}</span>
</div>
))}
</div>
)}
<div className="flex items-center justify-end pl-8">
<div className="flex items-center gap-2">

View File

@@ -79,7 +79,7 @@ export function ExamActions({ exam }: ExamActionsProps) {
toast.error("Failed to load exam preview")
setShowViewDialog(false)
}
} catch (e) {
} catch {
toast.error("Failed to load exam preview")
setShowViewDialog(false)
} finally {

View File

@@ -1,7 +1,6 @@
"use client"
import { useDeferredValue, useMemo, useState, useTransition, useEffect, useRef } from "react"
import { useFormStatus } from "react-dom"
import { useCallback, useDeferredValue, useMemo, useState, useTransition, useEffect, useRef } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { Search, Eye } from "lucide-react"
@@ -34,15 +33,6 @@ type ExamAssemblyProps = {
questionOptions: Question[]
}
function SubmitButton({ label }: { label: string }) {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending} className="w-full">
{pending ? "Saving..." : label}
</Button>
)
}
export function ExamAssembly(props: ExamAssemblyProps) {
const router = useRouter()
const [search, setSearch] = useState("")
@@ -83,7 +73,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
return []
})
const fetchQuestions = (reset: boolean = false) => {
const fetchQuestions = useCallback((reset: boolean = false) => {
startBankTransition(async () => {
const nextPage = reset ? 1 : page + 1
try {
@@ -107,11 +97,11 @@ export function ExamAssembly(props: ExamAssemblyProps) {
setHasMore(result.data.length === 20)
setPage(nextPage)
}
} catch (error) {
} catch {
toast.error("Failed to load questions")
}
})
}
}, [deferredSearch, page, startBankTransition, typeFilter, difficultyFilter])
const isFirstRender = useRef(true)
@@ -123,7 +113,7 @@ export function ExamAssembly(props: ExamAssemblyProps) {
}
}
fetchQuestions(true)
}, [deferredSearch, typeFilter, difficultyFilter])
}, [deferredSearch, typeFilter, difficultyFilter, fetchQuestions])
// Recursively calculate total score
const assignedTotal = useMemo(() => {

View File

@@ -149,8 +149,8 @@ export const omitScheduledAtFromDescription = (description: string | null): stri
try {
const meta = JSON.parse(description)
if (typeof meta === "object" && meta !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { scheduledAt, ...rest } = meta as any
const rest = { ...(meta as Record<string, unknown>) }
delete rest.scheduledAt
return JSON.stringify(rest)
}
return description