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:
SpecialX
2026-06-23 17:38:56 +08:00
parent 1a9377222c
commit 4f0ef217a0
56 changed files with 1251 additions and 850 deletions

View File

@@ -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)
}
}

View File

@@ -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())

View File

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

View File

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

View File

@@ -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>
)

View File

@@ -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 || "未命名题目"}

View File

@@ -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 } }
})
}}
>

View File

@@ -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__"))
}
}
}
}

View File

@@ -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)