refactor(modules): update existing module implementations across attendance, audit, auth, classes, course-plans, exams, files, homework, layout, proctoring, questions, scheduling, textbooks, users
- Update attendance components and data-access for record management - Update audit log views, filters, and data-access - Update auth login and register forms - Update classes actions, components, and data-access (admin, schedule, stats) - Update course-plans actions, form, list, progress, and schema - Update exams actions, AI pipeline, preview components, and hooks - Update files components (icon, list, preview, upload) and data-access - Update homework assignment form, review view, auto-save hook, and stats-service - Update layout sidebar, header, and navigation config - Update proctoring actions, anti-cheat monitor, and data-access - Update questions actions, components (dialog, actions, columns, filters), and data-access - Update scheduling actions, auto-scheduler, components, and schema - Update textbooks constants and text-selection hook - Update users class-registration, import-dialog, data-access, and user-service
This commit is contained in:
@@ -6,6 +6,11 @@ import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guar
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { z } from "zod"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import {
|
||||
handleActionError,
|
||||
safeJsonParse,
|
||||
} from "@/shared/lib/action-utils"
|
||||
import { trackExamEvent } from "@/shared/lib/track-event"
|
||||
import {
|
||||
buildExamDescription,
|
||||
deleteExamById,
|
||||
@@ -73,12 +78,15 @@ const parseExamModeConfig = (formData: FormData): ExamModeConfig => {
|
||||
const durationMinutes = rawDuration && Number.isFinite(Number(rawDuration))
|
||||
? Number(rawDuration)
|
||||
: null
|
||||
const rawGrace = getStringValue(formData, "lateStartGraceMinutes") ?? "0"
|
||||
const parsedGrace = Number(rawGrace)
|
||||
const lateStartGraceMinutes = Number.isFinite(parsedGrace) ? parsedGrace : 0
|
||||
return {
|
||||
examMode,
|
||||
durationMinutes,
|
||||
shuffleQuestions: getBoolValue(formData, "shuffleQuestions", false),
|
||||
allowLateStart: getBoolValue(formData, "allowLateStart", false),
|
||||
lateStartGraceMinutes: Number(getStringValue(formData, "lateStartGraceMinutes") ?? "0") || 0,
|
||||
lateStartGraceMinutes,
|
||||
antiCheatEnabled: getBoolValue(formData, "antiCheatEnabled", false),
|
||||
}
|
||||
}
|
||||
@@ -315,7 +323,7 @@ export async function createExamAction(
|
||||
totalScore: getStringValue(formData, "totalScore"),
|
||||
durationMin: getStringValue(formData, "durationMin"),
|
||||
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
|
||||
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
|
||||
questions: rawQuestions ? safeJsonParse(rawQuestions, "题目数据格式无效") : [],
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
@@ -345,7 +353,7 @@ export async function createExamAction(
|
||||
examModeConfig: parseExamModeConfig(formData),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to create exam:", error)
|
||||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||||
return failState<string>("Database error: Failed to create exam")
|
||||
}
|
||||
|
||||
@@ -356,7 +364,7 @@ export async function createExamAction(
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<string>(error.message)
|
||||
}
|
||||
throw error
|
||||
return handleActionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,7 +411,7 @@ export async function createAiExamAction(
|
||||
totalScore: getStringValue(formData, "totalScore"),
|
||||
durationMin: getStringValue(formData, "durationMin"),
|
||||
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
|
||||
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
|
||||
questions: rawQuestions ? safeJsonParse(rawQuestions, "题目数据格式无效") : [],
|
||||
aiSourceText: typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : undefined,
|
||||
aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0
|
||||
? aiQuestionCountRaw
|
||||
@@ -465,18 +473,28 @@ export async function createAiExamAction(
|
||||
examModeConfig: parseExamModeConfig(formData),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to create exam:", error)
|
||||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||||
return failState<string>("Database error: Failed to create exam")
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
// V3-4: 埋点监控(AI 生成考试)
|
||||
await trackExamEvent("exam.ai_generated", {
|
||||
userId: ctx.userId,
|
||||
targetId: context.examId,
|
||||
properties: {
|
||||
aiSourceText: input.aiSourceText?.length ?? 0,
|
||||
aiQuestionCount: input.aiQuestionCount,
|
||||
},
|
||||
})
|
||||
|
||||
return successState(context.examId, "Exam created successfully.")
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<string>(error.message)
|
||||
}
|
||||
throw error
|
||||
return handleActionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,7 +547,7 @@ export async function previewAiExamAction(
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<AiPreviewData>(error.message)
|
||||
}
|
||||
throw error
|
||||
return handleActionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,14 +583,15 @@ export async function regenerateAiQuestionAction(
|
||||
score: result.data.score ?? originalScore,
|
||||
content: result.data.content,
|
||||
})
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||||
return failState<AiRewriteQuestionData>("AI question format invalid")
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<AiRewriteQuestionData>(error.message)
|
||||
}
|
||||
throw error
|
||||
return handleActionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -599,13 +618,13 @@ export async function updateExamAction(
|
||||
|
||||
const rawQuestions = formData.get("questionsJson")
|
||||
const rawStructure = formData.get("structureJson")
|
||||
const hasQuestions = typeof rawQuestions === "string"
|
||||
const hasStructure = typeof rawStructure === "string"
|
||||
const rawQuestionsStr = typeof rawQuestions === "string" ? rawQuestions : null
|
||||
const rawStructureStr = typeof rawStructure === "string" ? rawStructure : null
|
||||
|
||||
const parsed = ExamUpdateSchema.safeParse({
|
||||
examId: formData.get("examId"),
|
||||
questions: hasQuestions ? JSON.parse(rawQuestions) : undefined,
|
||||
structure: hasStructure ? JSON.parse(rawStructure) : undefined,
|
||||
questions: rawQuestionsStr ? safeJsonParse(rawQuestionsStr, "题目数据格式无效") : undefined,
|
||||
structure: rawStructureStr ? safeJsonParse(rawStructureStr, "试卷结构数据格式无效") : undefined,
|
||||
status: formData.get("status") ?? undefined,
|
||||
})
|
||||
|
||||
@@ -632,18 +651,26 @@ export async function updateExamAction(
|
||||
structure,
|
||||
status,
|
||||
})
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||||
return failState<string>("Database error: Failed to update exam")
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
// V3-4: 埋点监控
|
||||
await trackExamEvent("exam.updated", {
|
||||
userId: ctx.userId,
|
||||
targetId: examId,
|
||||
properties: { hasQuestions: !!questions, hasStructure: !!structure, status },
|
||||
})
|
||||
|
||||
return successState(examId, "Exam updated")
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<string>(error.message)
|
||||
}
|
||||
throw error
|
||||
return handleActionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,18 +708,25 @@ export async function deleteExamAction(
|
||||
|
||||
try {
|
||||
await deleteExamById(examId)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||||
return failState<string>("Database error: Failed to delete exam")
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
// V3-4: 埋点监控
|
||||
await trackExamEvent("exam.deleted", {
|
||||
userId: ctx.userId,
|
||||
targetId: examId,
|
||||
})
|
||||
|
||||
return successState(examId, "Exam deleted")
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<string>(error.message)
|
||||
}
|
||||
throw error
|
||||
return handleActionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,18 +761,26 @@ export async function duplicateExamAction(
|
||||
return failState<string>("Exam not found")
|
||||
}
|
||||
newExamId = duplicatedId
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||||
return failState<string>("Database error: Failed to duplicate exam")
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
// V3-4: 埋点监控
|
||||
await trackExamEvent("exam.duplicated", {
|
||||
userId: ctx.userId,
|
||||
targetId: newExamId,
|
||||
properties: { sourceExamId: examId },
|
||||
})
|
||||
|
||||
return successState(newExamId, "Exam duplicated")
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<string>(error.message)
|
||||
}
|
||||
throw error
|
||||
return handleActionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,14 +801,14 @@ export async function getExamPreviewAction(
|
||||
questions: exam.questions,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||||
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Failed to load exam preview")
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<{ structure: unknown; questions: Array<{ id: string }> }>(error.message)
|
||||
}
|
||||
throw error
|
||||
return handleActionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -778,14 +820,14 @@ export async function getSubjectsAction(): Promise<ActionState<{ id: string; nam
|
||||
const allSubjects = await getExamSubjects()
|
||||
return successState(allSubjects)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch subjects:", error)
|
||||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||||
return failState<{ id: string; name: string }[]>("Failed to load subjects")
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<{ id: string; name: string }[]>(error.message)
|
||||
}
|
||||
throw error
|
||||
return handleActionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -797,14 +839,14 @@ export async function getGradesAction(): Promise<ActionState<{ id: string; name:
|
||||
const allGrades = await getExamGrades()
|
||||
return successState(allGrades)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch grades:", error)
|
||||
console.error("[ExamAction]", error instanceof Error ? error.message : String(error))
|
||||
return failState<{ id: string; name: string }[]>("Failed to load grades")
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<{ id: string; name: string }[]>(error.message)
|
||||
}
|
||||
throw error
|
||||
return handleActionError(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,13 @@ export const mapWithConcurrency = async <T, R>(
|
||||
while (cursor < items.length) {
|
||||
const index = cursor
|
||||
cursor += 1
|
||||
results[index] = await worker(items[index], index)
|
||||
try {
|
||||
results[index] = await worker(items[index], index)
|
||||
} catch (error) {
|
||||
// Catch per-item errors so a single failure doesn't reject the whole batch.
|
||||
// The result slot stays undefined; callers should handle missing entries.
|
||||
console.error("[mapWithConcurrency] worker error at index", index, error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
const workers = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) }, () => runWorker())
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import type { ExamNode } from "./selected-question-list"
|
||||
|
||||
type ChoiceOption = {
|
||||
@@ -21,23 +22,41 @@ type ExamPaperPreviewProps = {
|
||||
nodes: ExamNode[]
|
||||
}
|
||||
|
||||
export function ExamPaperPreview({ title, subject, grade, durationMin, totalScore, nodes }: ExamPaperPreviewProps) {
|
||||
// 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 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 }
|
||||
// Precompute question numbers as a Map to avoid mutating a counter during render
|
||||
const buildQuestionNumberMap = (nodes: ExamNode[]): Map<string, number> => {
|
||||
const map = new Map<string, number>()
|
||||
let counter = 0
|
||||
const walk = (list: ExamNode[]) => {
|
||||
for (const node of list) {
|
||||
if (node.type === "question" && node.question) {
|
||||
counter += 1
|
||||
map.set(node.id, counter)
|
||||
} else if (node.type === "group" && node.children) {
|
||||
walk(node.children)
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
walk(nodes)
|
||||
return map
|
||||
}
|
||||
|
||||
export function ExamPaperPreview({ title, subject, grade, durationMin, totalScore, nodes }: ExamPaperPreviewProps) {
|
||||
// Stable numbering map - recomputed only when nodes change. Avoids StrictMode double-increment.
|
||||
const numberMap = useMemo(() => buildQuestionNumberMap(nodes), [nodes])
|
||||
|
||||
const renderNode = (node: ExamNode, depth: number = 0) => {
|
||||
if (node.type === 'group') {
|
||||
@@ -57,20 +76,20 @@ export function ExamPaperPreview({ title, subject, grade, durationMin, totalScor
|
||||
}
|
||||
|
||||
if (node.type === 'question' && node.question) {
|
||||
questionCounter++
|
||||
const questionNumber = numberMap.get(node.id) ?? 0
|
||||
const q = node.question
|
||||
const content = parseContent(q.content)
|
||||
|
||||
|
||||
return (
|
||||
<div key={node.id} className="mb-6 break-inside-avoid">
|
||||
<div className="flex gap-2">
|
||||
<span className="font-semibold text-foreground min-w-[24px]">{questionCounter}.</span>
|
||||
<span className="font-semibold text-foreground min-w-[24px]">{questionNumber}.</span>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
|
||||
{content.text ?? ""}
|
||||
{content.text ?? ""}
|
||||
<span className="text-muted-foreground text-sm ml-2">({node.score}分)</span>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Options for Choice Questions */}
|
||||
{(q.type === 'single_choice' || q.type === 'multiple_choice') && content.options && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-2 gap-x-4 mt-2 pl-2">
|
||||
|
||||
@@ -68,7 +68,7 @@ export function SelectedQuestionList({
|
||||
</div>
|
||||
|
||||
<div className="pl-4 border-l-2 border-muted space-y-3">
|
||||
{node.children?.length === 0 ? (
|
||||
{!node.children || node.children.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground italic py-2">Drag questions here or add from bank</div>
|
||||
) : (
|
||||
node.children?.map((child, cIdx) => (
|
||||
@@ -191,13 +191,13 @@ function QuestionItem({ item, index, total, onRemove, onMove, onScoreChange }: {
|
||||
<Label htmlFor={`score-${item.id}`} className="text-xs text-muted-foreground">
|
||||
Score
|
||||
</Label>
|
||||
<Input
|
||||
<Input
|
||||
id={`score-${item.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-7 w-16 text-right"
|
||||
value={item.score}
|
||||
onChange={(e) => onScoreChange(parseInt(e.target.value) || 0)}
|
||||
onChange={(e) => onScoreChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import React, { useMemo, useState } from "react"
|
||||
import React, { useCallback, useMemo, useState } from "react"
|
||||
import {
|
||||
DndContext,
|
||||
pointerWithin,
|
||||
@@ -54,6 +54,30 @@ function cloneExamNodes(nodes: ExamNode[]): ExamNode[] {
|
||||
})
|
||||
}
|
||||
|
||||
// Safely extract a text preview from a question's content (which may be a string,
|
||||
// object, or JSON string). Avoids `as` assertions by runtime narrowing.
|
||||
const extractQuestionText = (raw: unknown): string => {
|
||||
if (!raw) return ""
|
||||
if (typeof raw === "string") {
|
||||
// Content might be a JSON string or plain text
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const obj = parsed as Record<string, unknown>
|
||||
return typeof obj.text === "string" ? obj.text : ""
|
||||
}
|
||||
return raw
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
if (typeof raw === "object") {
|
||||
const obj = raw as Record<string, unknown>
|
||||
return typeof obj.text === "string" ? obj.text : ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- Components ---
|
||||
|
||||
function SortableItem({
|
||||
@@ -135,13 +159,13 @@ function SortableItem({
|
||||
<Label htmlFor={`score-${item.id}`} className="text-xs text-muted-foreground">
|
||||
Score
|
||||
</Label>
|
||||
<Input
|
||||
<Input
|
||||
id={`score-${item.id}`}
|
||||
type="number"
|
||||
min={0}
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-7 w-16 text-right"
|
||||
value={item.score}
|
||||
onChange={(e) => onScoreChange(parseInt(e.target.value) || 0)}
|
||||
onChange={(e) => onScoreChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,6 +203,7 @@ function SortableGroup({
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
|
||||
const childrenKey = JSON.stringify(item.children || [])
|
||||
const totalScore = useMemo(() => {
|
||||
const calc = (nodes: ExamNode[]): number => {
|
||||
return nodes.reduce((acc, node) => {
|
||||
@@ -188,7 +213,8 @@ function SortableGroup({
|
||||
}, 0)
|
||||
}
|
||||
return calc(item.children || [])
|
||||
}, [item])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [childrenKey])
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen} ref={setNodeRef} style={style} className={cn("rounded-lg border bg-muted/10 p-3 space-y-2", isDragging && "ring-2 ring-primary")}>
|
||||
@@ -227,13 +253,14 @@ function SortableGroup({
|
||||
)
|
||||
}
|
||||
|
||||
function StructureRenderer({ nodes, ...props }: {
|
||||
nodes: ExamNode[]
|
||||
function StructureRenderer({ nodes, ...props }: {
|
||||
nodes: ExamNode[]
|
||||
onRemove: (id: string) => void
|
||||
onScoreChange: (id: string, score: number) => void
|
||||
onGroupTitleChange: (id: string, title: string) => void
|
||||
}) {
|
||||
// Deduplicate nodes to prevent React key errors
|
||||
const nodesKey = JSON.stringify(nodes.map(n => n.id))
|
||||
const uniqueNodes = useMemo(() => {
|
||||
const seen = new Set()
|
||||
return nodes.filter(n => {
|
||||
@@ -241,7 +268,8 @@ function StructureRenderer({ nodes, ...props }: {
|
||||
seen.add(n.id)
|
||||
return true
|
||||
})
|
||||
}, [nodes])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nodesKey])
|
||||
|
||||
return (
|
||||
<SortableContext items={uniqueNodes.map(n => n.id)} strategy={verticalListSortingStrategy}>
|
||||
@@ -294,7 +322,7 @@ const dropAnimation: DropAnimation = {
|
||||
|
||||
export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleChange, onRemove, onAddGroup }: StructureEditorProps) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
@@ -303,30 +331,33 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
|
||||
)
|
||||
|
||||
// Recursively find item
|
||||
const findItem = (id: string, nodes: ExamNode[] = items): ExamNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node
|
||||
if (node.children) {
|
||||
const found = findItem(id, node.children)
|
||||
if (found) return found
|
||||
const findItem = useCallback((id: string, nodes: ExamNode[] = items): ExamNode | null => {
|
||||
const walk = (list: ExamNode[]): ExamNode | null => {
|
||||
for (const node of list) {
|
||||
if (node.id === id) return node
|
||||
if (node.children) {
|
||||
const found = walk(node.children)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
return walk(nodes)
|
||||
}, [items])
|
||||
|
||||
const activeItem = activeId ? findItem(activeId) : null
|
||||
|
||||
// DND Handlers
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Custom collision detection for nested sortables
|
||||
const customCollisionDetection: CollisionDetection = (args) => {
|
||||
const customCollisionDetection: CollisionDetection = useCallback((args) => {
|
||||
// 1. First check pointer within for precise container detection
|
||||
const pointerCollisions = pointerWithin(args)
|
||||
|
||||
|
||||
// If we have pointer collisions, prioritize the most specific one (usually the smallest/innermost container)
|
||||
if (pointerCollisions.length > 0) {
|
||||
return pointerCollisions
|
||||
@@ -334,7 +365,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
|
||||
|
||||
// 2. Fallback to rect intersection for smoother sortable reordering when not directly over a container
|
||||
return rectIntersection(args)
|
||||
}
|
||||
}, [])
|
||||
|
||||
function handleDragOver(event: DragOverEvent) {
|
||||
const { active, over } = event
|
||||
@@ -557,15 +588,15 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||
const moved = arrayMove(list, oldIndex, newIndex)
|
||||
|
||||
|
||||
// Update the list reference in parent
|
||||
if (activeContainerId === 'root') {
|
||||
onChange(moved)
|
||||
} else {
|
||||
} else if (activeContainerId) {
|
||||
// list is already a reference to children array if we did it right?
|
||||
// getMutableList returned `group.children`. Modifying `list` directly via arrayMove returns NEW array.
|
||||
// So we need to re-assign.
|
||||
const group = findItem(activeContainerId!, newItems)
|
||||
const group = findItem(activeContainerId, newItems)
|
||||
if (group) group.children = moved
|
||||
onChange(newItems)
|
||||
}
|
||||
@@ -611,7 +642,7 @@ export function StructureEditor({ items, onChange, onScoreChange, onGroupTitleCh
|
||||
<div className="rounded-md border bg-background p-3 shadow-lg opacity-80 w-[300px] flex items-center gap-3">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
<p className="text-sm line-clamp-1">
|
||||
{(activeItem.question?.content as { text?: string } | undefined)?.text || "Question"}
|
||||
{extractQuestionText(activeItem.question?.content) || "Question"}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { useMemo, type ReactNode } from "react"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
@@ -37,6 +37,24 @@ type ExamPreviewDialogProps = {
|
||||
previewTitleValue?: string
|
||||
}
|
||||
|
||||
// Precompute question numbers as a Map to avoid mutating a counter during render
|
||||
const buildQuestionNumberMap = (nodes: ExamNode[]): Map<string, number> => {
|
||||
const map = new Map<string, number>()
|
||||
let counter = 0
|
||||
const walk = (list: ExamNode[]) => {
|
||||
for (const node of list) {
|
||||
if (node.type === "question" && node.question && node.questionId) {
|
||||
counter += 1
|
||||
map.set(node.id, counter)
|
||||
} else if (node.type === "group" && node.children) {
|
||||
walk(node.children)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(nodes)
|
||||
return map
|
||||
}
|
||||
|
||||
export function ExamPreviewDialog({
|
||||
previewOpen,
|
||||
setPreviewOpen,
|
||||
@@ -59,8 +77,10 @@ export function ExamPreviewDialog({
|
||||
handleConfirmCreate,
|
||||
previewTitleValue,
|
||||
}: ExamPreviewDialogProps) {
|
||||
// Stable numbering map - recomputed only when nodes change. Avoids StrictMode double-increment.
|
||||
const numberMap = useMemo(() => buildQuestionNumberMap(previewNodes), [previewNodes])
|
||||
|
||||
const renderSelectablePreview = (nodes: ExamNode[]) => {
|
||||
let questionCounter = 0
|
||||
const renderNode = (node: ExamNode, depth: number = 0): ReactNode => {
|
||||
if (node.type === "group") {
|
||||
return (
|
||||
@@ -75,7 +95,7 @@ export function ExamPreviewDialog({
|
||||
)
|
||||
}
|
||||
if (node.type === "question" && node.question && node.questionId) {
|
||||
questionCounter += 1
|
||||
const questionNumber = numberMap.get(node.id) ?? 0
|
||||
const content = parseEditableContent(node.question.content)
|
||||
const active = node.questionId === selectedQuestionId
|
||||
return (
|
||||
@@ -89,7 +109,7 @@ export function ExamPreviewDialog({
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<span className="font-semibold text-foreground min-w-[28px]">{questionCounter}.</span>
|
||||
<span className="font-semibold text-foreground min-w-[28px]">{questionNumber}.</span>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
|
||||
{content.text || "未命名题目"}
|
||||
|
||||
@@ -13,11 +13,23 @@ import {
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import type { ExamNode } from "./assembly/selected-question-list"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
import type { QuestionType } from "@/modules/questions/types"
|
||||
import type { EditableQuestionContent } from "./exam-form-types"
|
||||
import { QuestionOptionsEditor } from "./question-options-editor"
|
||||
import { QuestionSubQuestionsEditor } from "./question-sub-questions-editor"
|
||||
|
||||
const QUESTION_TYPES: readonly QuestionType[] = [
|
||||
"single_choice",
|
||||
"multiple_choice",
|
||||
"text",
|
||||
"judgment",
|
||||
"composite",
|
||||
] as const
|
||||
|
||||
function isQuestionType(value: string): value is QuestionType {
|
||||
return (QUESTION_TYPES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
type ExamPreviewQuestionEditorProps = {
|
||||
selectedQuestion: ExamNode | null
|
||||
selectedContent: EditableQuestionContent | null
|
||||
@@ -67,7 +79,9 @@ export function ExamPreviewQuestionEditor({
|
||||
onValueChange={(value) => {
|
||||
updatePreviewQuestionNode(selectedQuestionId, (node) => {
|
||||
if (!node.question) return node
|
||||
return { ...node, question: { ...node.question, type: value as Question["type"] } }
|
||||
// Use type guard to narrow string to Question["type"] instead of `as` assertion
|
||||
if (!isQuestionType(value)) return node
|
||||
return { ...node, question: { ...node.question, type: value } }
|
||||
})
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createId } from "@paralleldrive/cuid2"
|
||||
import { createQuestionWithRelations } from "@/modules/questions/data-access"
|
||||
import { getClassGradeIdsByClassIds } from "@/modules/classes/data-access"
|
||||
import { getSubjectNameById, getGradeNameById, getSubjectOptions, getGradeOptions } from "@/modules/school/data-access"
|
||||
import { escapeLikePattern } from "@/shared/lib/action-utils"
|
||||
|
||||
import type { Exam, ExamDifficulty, ExamStatus } from "./types"
|
||||
import type { AiGeneratedQuestion, AiGeneratedStructureNode } from "./ai-pipeline"
|
||||
@@ -64,7 +65,7 @@ export const getExams = cache(async (params: GetExamsParams & { scope: DataScope
|
||||
const conditions = []
|
||||
|
||||
if (params.q) {
|
||||
const search = `%${params.q}%`
|
||||
const search = `%${escapeLikePattern(params.q)}%`
|
||||
conditions.push(or(like(exams.title, search), like(exams.description, search)))
|
||||
}
|
||||
|
||||
@@ -82,10 +83,19 @@ export const getExams = cache(async (params: GetExamsParams & { scope: DataScope
|
||||
const gradeIds = Array.from(new Set(classGradeMap.values()))
|
||||
if (gradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, gradeIds))
|
||||
} else {
|
||||
// P0 fix: empty grade set must NOT bypass filtering (would expose all exams)
|
||||
conditions.push(eq(exams.id, "__none__"))
|
||||
}
|
||||
} else if (params.scope.type === "class_taught") {
|
||||
// P0 fix: class_taught scope with no classIds must return nothing
|
||||
conditions.push(eq(exams.id, "__none__"))
|
||||
}
|
||||
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, params.scope.gradeIds))
|
||||
} else if (params.scope.type === "grade_managed") {
|
||||
// P0 fix: grade_managed scope with no gradeIds must return nothing
|
||||
conditions.push(eq(exams.id, "__none__"))
|
||||
}
|
||||
// "all" type: no filtering
|
||||
// "class_members": student sees published exams for their grade (would need student's gradeId)
|
||||
@@ -126,8 +136,10 @@ export const getExams = cache(async (params: GetExamsParams & { scope: DataScope
|
||||
})
|
||||
|
||||
if (params.difficulty && params.difficulty !== "all") {
|
||||
const d = parseInt(params.difficulty)
|
||||
result = result.filter((e) => e.difficulty === d)
|
||||
const d = parseInt(params.difficulty, 10)
|
||||
if (!Number.isNaN(d)) {
|
||||
result = result.filter((e) => e.difficulty === d)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -155,13 +167,20 @@ export const getExamById = cache(async (id: string, scope?: DataScope) => {
|
||||
if (scope.type === "owned" && exam.creatorId !== scope.userId) {
|
||||
return null
|
||||
}
|
||||
if (scope.type === "grade_managed" && scope.gradeIds.length > 0 && !scope.gradeIds.includes(exam.gradeId ?? "")) {
|
||||
return null
|
||||
if (scope.type === "grade_managed") {
|
||||
// P0 fix: empty gradeIds must NOT bypass filtering (would leak exam details)
|
||||
if (scope.gradeIds.length === 0) return null
|
||||
if (!scope.gradeIds.includes(exam.gradeId ?? "")) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
if (scope.type === "class_taught" && scope.classIds.length > 0) {
|
||||
if (scope.type === "class_taught") {
|
||||
// P0 fix: empty classIds must NOT bypass filtering (would leak exam details)
|
||||
if (scope.classIds.length === 0) return null
|
||||
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
|
||||
const gradeIds = Array.from(new Set(classGradeMap.values()))
|
||||
if (gradeIds.length > 0 && !gradeIds.includes(exam.gradeId ?? "")) {
|
||||
if (gradeIds.length === 0) return null
|
||||
if (!gradeIds.includes(exam.gradeId ?? "")) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -182,7 +201,7 @@ export const getExamById = cache(async (id: string, scope?: DataScope) => {
|
||||
createdAt: exam.createdAt.toISOString(),
|
||||
updatedAt: exam.updatedAt?.toISOString(),
|
||||
tags: getStringArray(meta, "tags") || [],
|
||||
structure: exam.structure as unknown,
|
||||
structure: exam.structure,
|
||||
questions: exam.questions.map((eqRel) => ({
|
||||
id: eqRel.questionId,
|
||||
score: eqRel.score ?? 0,
|
||||
@@ -379,14 +398,26 @@ export const getExamsDashboardStats = cache(async (scope?: DataScope): Promise<E
|
||||
if (scope.type === "owned") {
|
||||
conditions.push(eq(exams.creatorId, scope.userId))
|
||||
}
|
||||
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, scope.gradeIds))
|
||||
if (scope.type === "grade_managed") {
|
||||
// P0 fix: empty gradeIds must NOT bypass filtering
|
||||
if (scope.gradeIds.length === 0) {
|
||||
conditions.push(eq(exams.id, "__none__"))
|
||||
} else {
|
||||
conditions.push(inArray(exams.gradeId, scope.gradeIds))
|
||||
}
|
||||
}
|
||||
if (scope.type === "class_taught" && scope.classIds.length > 0) {
|
||||
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
|
||||
const gradeIds = Array.from(new Set(classGradeMap.values()))
|
||||
if (gradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, gradeIds))
|
||||
if (scope.type === "class_taught") {
|
||||
// P0 fix: empty classIds must NOT bypass filtering
|
||||
if (scope.classIds.length === 0) {
|
||||
conditions.push(eq(exams.id, "__none__"))
|
||||
} else {
|
||||
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
|
||||
const gradeIds = Array.from(new Set(classGradeMap.values()))
|
||||
if (gradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, gradeIds))
|
||||
} else {
|
||||
conditions.push(eq(exams.id, "__none__"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,17 @@ import {
|
||||
buildPreviewRequestData,
|
||||
} from "../components/exam-preview-utils"
|
||||
|
||||
// Runtime validator for parsed preview background tasks.
|
||||
// Avoids trusting JSON.parse output blindly.
|
||||
const isPreviewBackgroundTask = (v: unknown): v is PreviewBackgroundTask => {
|
||||
if (!v || typeof v !== "object") return false
|
||||
const obj = v as Record<string, unknown>
|
||||
return typeof obj.id === "string"
|
||||
&& (obj.status === "queued" || obj.status === "running" || obj.status === "success" || obj.status === "failed")
|
||||
&& typeof obj.createdAt === "number"
|
||||
&& typeof obj.title === "string"
|
||||
}
|
||||
|
||||
export function useExamPreview(form: UseFormReturn<ExamFormValues>) {
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
@@ -48,7 +59,7 @@ export function useExamPreview(form: UseFormReturn<ExamFormValues>) {
|
||||
try {
|
||||
window.localStorage.setItem(previewTaskStorageKey, JSON.stringify(tasks.slice(0, 20)))
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,10 +67,16 @@ export function useExamPreview(form: UseFormReturn<ExamFormValues>) {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(previewTaskStorageKey)
|
||||
if (!raw) return
|
||||
const parsed = JSON.parse(raw) as PreviewBackgroundTask[]
|
||||
let parsed: unknown = null
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch (error) {
|
||||
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
|
||||
return
|
||||
}
|
||||
if (!Array.isArray(parsed)) return
|
||||
const restoredTasks = parsed
|
||||
.filter((task) => task && typeof task.id === "string")
|
||||
.filter(isPreviewBackgroundTask)
|
||||
.map((task) => {
|
||||
if (task.status === "queued" || task.status === "running") {
|
||||
return {
|
||||
@@ -75,7 +92,7 @@ export function useExamPreview(form: UseFormReturn<ExamFormValues>) {
|
||||
form.setValue("mode", "ai")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
|
||||
setPreviewTasks([])
|
||||
}
|
||||
}, [form])
|
||||
@@ -150,7 +167,8 @@ export function useExamPreview(form: UseFormReturn<ExamFormValues>) {
|
||||
} else {
|
||||
toast.error(result.message || "Failed to generate preview")
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
|
||||
toast.error("Failed to generate preview")
|
||||
} finally {
|
||||
setPreviewLoading(false)
|
||||
@@ -201,7 +219,8 @@ export function useExamPreview(form: UseFormReturn<ExamFormValues>) {
|
||||
? { ...task, status: "failed", message: result.message || "Failed to generate preview" }
|
||||
: task))
|
||||
toast.error(`后台生成失败:${taskTitle}`)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
|
||||
setPreviewTasks((prev) => prev.map((task) => task.id === taskId
|
||||
? { ...task, status: "failed", message: "Failed to generate preview" }
|
||||
: task))
|
||||
@@ -276,7 +295,8 @@ export function useExamPreview(form: UseFormReturn<ExamFormValues>) {
|
||||
updateSelectedQuestionFromAi(selectedQuestionId, result.data)
|
||||
setRewriteInstruction("")
|
||||
toast.success("题目已按指令重写")
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("[useExamPreview]", error instanceof Error ? error.message : String(error))
|
||||
toast.error("AI 重写失败")
|
||||
} finally {
|
||||
setRewritingQuestion(false)
|
||||
|
||||
Reference in New Issue
Block a user