feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
主要变更: - 新增 lesson-preparation 模块: 备课编辑器、节点编辑、AI 建议、知识点选择、版本历史、作业发布 - 新增 shared 通用组件: charts/question-bank-filters/schedule-list/ui (chip-nav/filter-bar/page-header/stat-card/stat-item) - 新增 student/admin 端 loading.tsx 与 error.tsx, 优化加载与错误态体验 - 新增 teacher/lesson-plans 页面 (列表/新建/编辑) - 新增 drizzle 迁移 0002_tiny_lionheart 及 snapshot - 新增 textbooks/schema.ts 与 exams/utils/normalize-structure.ts - 修复 Tiptap v3 SSR hydration 崩溃 (rich-text-block immediatelyRender: false) - 重构多模块 data-access/actions/组件, 修复权限校验与类型规范 - 同步架构文档 004/005 反映新增模块、导出、依赖关系 - 归档 bugs/* 测试报告与 e2e 测试脚本 (admin/parent/student/teacher web_test)
This commit is contained in:
@@ -137,12 +137,24 @@ const loadAiDraftQuestionsAndStructure = async (input: {
|
||||
if (!validated.success || validated.data.length === 0) {
|
||||
return { ok: false, message: "Invalid AI preview payload" }
|
||||
}
|
||||
const generated = validated.data.map((q) => ({
|
||||
const generated: AiGeneratedQuestion[] = validated.data.map((q) => ({
|
||||
id: q.id,
|
||||
type: q.type,
|
||||
difficulty: q.difficulty,
|
||||
content: q.content,
|
||||
score: q.score,
|
||||
content: {
|
||||
text: q.content.text,
|
||||
...(q.content.options
|
||||
? {
|
||||
options: q.content.options.map((opt) => ({
|
||||
id: opt.id,
|
||||
text: opt.text,
|
||||
isCorrect: opt.isCorrect ?? false,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
...(q.content.subQuestions ? { subQuestions: q.content.subQuestions } : {}),
|
||||
},
|
||||
}))
|
||||
let structure: AiGeneratedStructureNode[] = []
|
||||
if (input.rawStructure) {
|
||||
|
||||
@@ -65,7 +65,7 @@ const AiExamResponseSchema = z.object({
|
||||
sections: z.array(AiSectionSchema).optional(),
|
||||
})
|
||||
|
||||
const sanitizeJsonCandidate = (value: string) => value
|
||||
const sanitizeJsonCandidate = (value: string): string => value
|
||||
.replace(/\[\s*\.\.\.\s*\]/g, "[]")
|
||||
.replace(/\{\s*\.\.\.\s*\}/g, "{}")
|
||||
.trim()
|
||||
@@ -174,7 +174,7 @@ const parseAiResponse = async (raw: string, providerId?: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeScores = (scores: number[], totalScore: number) => {
|
||||
const normalizeScores = (scores: number[], totalScore: number): number[] => {
|
||||
if (scores.length === 0) return []
|
||||
const sum = scores.reduce((acc, s) => acc + s, 0)
|
||||
if (sum <= 0) {
|
||||
@@ -306,6 +306,8 @@ const AI_QUESTION_DETAIL_SYSTEM_PROMPT = [
|
||||
"Never output placeholders like ..., [...], or {...}.",
|
||||
].join("\n")
|
||||
|
||||
type AiChatMessage = { role: "system" | "user"; content: string }
|
||||
|
||||
const buildAiMessages = (input: {
|
||||
title?: string
|
||||
subject?: string
|
||||
@@ -315,7 +317,7 @@ const buildAiMessages = (input: {
|
||||
durationMin?: number
|
||||
questionCount?: number
|
||||
sourceText: string
|
||||
}) => {
|
||||
}): AiChatMessage[] => {
|
||||
const userLines = [
|
||||
input.title ? `Title: ${input.title}` : "",
|
||||
input.subject ? `Subject: ${input.subject}` : "",
|
||||
@@ -450,7 +452,7 @@ const validateExamSourceText = async (input: { sourceText: string; aiProviderId?
|
||||
}
|
||||
}
|
||||
|
||||
const splitStructureItems = (draft: z.infer<typeof AiStructureResponseSchema>) => {
|
||||
const splitStructureItems = (draft: z.infer<typeof AiStructureResponseSchema>): SplitQuestionItem[] => {
|
||||
const hasSections = Array.isArray(draft.sections) && draft.sections.length > 0
|
||||
if (!hasSections) {
|
||||
return (draft.questions ?? []).map((q) => ({
|
||||
@@ -481,7 +483,7 @@ const mapWithConcurrency = async <T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
worker: (item: T, index: number) => Promise<R>
|
||||
) => {
|
||||
): Promise<R[]> => {
|
||||
const results = new Array<R>(items.length)
|
||||
let cursor = 0
|
||||
const runWorker = async () => {
|
||||
@@ -502,7 +504,7 @@ const parseQuestionDetail = async (input: {
|
||||
grade?: string
|
||||
difficulty: number
|
||||
aiProviderId?: string
|
||||
}) => {
|
||||
}): Promise<z.infer<typeof AiQuestionSchema>> => {
|
||||
const normalizeQuestionCandidate = (value: unknown): unknown => {
|
||||
if (!value || typeof value !== "object") return value
|
||||
const record = value as Record<string, unknown>
|
||||
@@ -568,7 +570,13 @@ const parseQuestionDetail = async (input: {
|
||||
} satisfies z.infer<typeof AiQuestionSchema>
|
||||
}
|
||||
|
||||
const buildQuestionContent = (q: z.infer<typeof AiQuestionSchema>) => {
|
||||
type QuestionContentResult = {
|
||||
text: string
|
||||
options?: Array<{ id: string; text: string; isCorrect: boolean }>
|
||||
subQuestions?: Array<{ id: string; text: string; answer?: string; score?: number }>
|
||||
}
|
||||
|
||||
const buildQuestionContent = (q: z.infer<typeof AiQuestionSchema>): QuestionContentResult => {
|
||||
const base = { text: q.content.text }
|
||||
const subQuestions = Array.isArray(q.content.subQuestions)
|
||||
? q.content.subQuestions.map((item, index) => ({
|
||||
@@ -709,7 +717,10 @@ const buildPreviewPayload = (
|
||||
}
|
||||
}
|
||||
|
||||
const previewToDraft = (preview: AiPreviewData) => {
|
||||
const previewToDraft = (preview: AiPreviewData): {
|
||||
generated: AiGeneratedQuestion[]
|
||||
structure: AiGeneratedStructureNode[]
|
||||
} => {
|
||||
const generated: AiGeneratedQuestion[] = []
|
||||
const structure: AiGeneratedStructureNode[] = []
|
||||
if (Array.isArray(preview.sections) && preview.sections.length > 0) {
|
||||
|
||||
@@ -3,14 +3,13 @@
|
||||
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"
|
||||
import { Eye } from "lucide-react"
|
||||
|
||||
import { Card, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog"
|
||||
import { QuestionBankFilters } from "@/shared/components/question/question-bank-filters"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
import { updateExamAction } from "@/modules/exams/actions"
|
||||
import { getQuestionsAction } from "@/modules/questions/actions"
|
||||
@@ -384,38 +383,15 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
{bankQuestions.length}{hasMore ? "+" : ""} loaded
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by content..."
|
||||
className="pl-9 h-9 text-sm"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="flex-1 h-8 text-xs bg-background"><SelectValue placeholder="Type" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="single_choice">Single Choice</SelectItem>
|
||||
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
|
||||
<SelectItem value="judgment">True/False</SelectItem>
|
||||
<SelectItem value="text">Short Answer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={difficultyFilter} onValueChange={setDifficultyFilter}>
|
||||
<SelectTrigger className="w-[80px] h-8 text-xs bg-background"><SelectValue placeholder="Diff" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="1">Lvl 1</SelectItem>
|
||||
<SelectItem value="2">Lvl 2</SelectItem>
|
||||
<SelectItem value="3">Lvl 3</SelectItem>
|
||||
<SelectItem value="4">Lvl 4</SelectItem>
|
||||
<SelectItem value="5">Lvl 5</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<QuestionBankFilters
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
type={typeFilter}
|
||||
onTypeChange={setTypeFilter}
|
||||
difficulty={difficultyFilter}
|
||||
onDifficultyChange={setDifficultyFilter}
|
||||
layout="compact"
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<ScrollArea className="flex-1 p-0 bg-muted/5">
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Search, X } from "lucide-react"
|
||||
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -11,24 +9,29 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
||||
|
||||
export function ExamFilters() {
|
||||
const [search, setSearch] = useQueryState("q", parseAsString.withOptions({ shallow: false }))
|
||||
const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false }))
|
||||
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withOptions({ shallow: false }))
|
||||
|
||||
const hasFilters = Boolean(search || (status && status !== "all") || (difficulty && difficulty !== "all"))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<div className="relative w-full md:w-80">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground/50" />
|
||||
<Input
|
||||
placeholder="Search exams..."
|
||||
className="pl-9 bg-background border-muted-foreground/20"
|
||||
value={search || ""}
|
||||
onChange={(e) => setSearch(e.target.value || null)}
|
||||
/>
|
||||
</div>
|
||||
<FilterBar
|
||||
hasFilters={hasFilters}
|
||||
onReset={() => {
|
||||
setSearch(null)
|
||||
setStatus(null)
|
||||
setDifficulty(null)
|
||||
}}
|
||||
>
|
||||
<FilterSearchInput
|
||||
value={search || ""}
|
||||
onChange={(v) => setSearch(v || null)}
|
||||
placeholder="Search exams..."
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2 w-full md:w-auto">
|
||||
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
||||
@@ -56,23 +59,7 @@ export function ExamFilters() {
|
||||
<SelectItem value="5">Hard (5)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{(search || (status && status !== "all") || (difficulty && difficulty !== "all")) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSearch(null)
|
||||
setStatus(null)
|
||||
setDifficulty(null)
|
||||
}}
|
||||
className="h-10 px-3"
|
||||
>
|
||||
Reset
|
||||
<X className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FilterBar>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -67,11 +67,11 @@ export function ExamForm() {
|
||||
} else {
|
||||
toast.error("Failed to load grades")
|
||||
}
|
||||
if (Array.isArray(aiProvidersResult)) {
|
||||
setAiProviders(aiProvidersResult)
|
||||
if (aiProvidersResult.success && aiProvidersResult.data) {
|
||||
setAiProviders(aiProvidersResult.data)
|
||||
const current = form.getValues("aiProviderId")
|
||||
if (!current) {
|
||||
const preferred = aiProvidersResult.find((item) => item.isDefault) ?? aiProvidersResult[0]
|
||||
const preferred = aiProvidersResult.data.find((item) => item.isDefault) ?? aiProvidersResult.data[0]
|
||||
if (preferred) {
|
||||
form.setValue("aiProviderId", preferred.id)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { db } from "@/shared/db"
|
||||
import { exams, examQuestions, examSubmissions, submissionAnswers, subjects, grades } from "@/shared/db/schema"
|
||||
import { exams, examQuestions, examSubmissions, submissionAnswers } from "@/shared/db/schema"
|
||||
import { count, eq, desc, like, and, or, inArray } from "drizzle-orm"
|
||||
import { cache } from "react"
|
||||
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 type { Exam, ExamDifficulty, ExamStatus } from "./types"
|
||||
import type { AiGeneratedQuestion, AiGeneratedStructureNode } from "./ai-pipeline"
|
||||
@@ -208,22 +209,14 @@ export const omitScheduledAtFromDescription = (description: string | null): stri
|
||||
export const resolveSubjectGradeNames = async (input: {
|
||||
subjectId?: string
|
||||
gradeId?: string
|
||||
}) => {
|
||||
const [subjectRecord, gradeRecord] = await Promise.all([
|
||||
input.subjectId
|
||||
? db.query.subjects.findFirst({
|
||||
where: eq(subjects.id, input.subjectId),
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
input.gradeId
|
||||
? db.query.grades.findFirst({
|
||||
where: eq(grades.id, input.gradeId),
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
}): Promise<{ subjectName: string | null; gradeName: string | null }> => {
|
||||
const [subjectName, gradeName] = await Promise.all([
|
||||
input.subjectId ? getSubjectNameById(input.subjectId) : Promise.resolve(null),
|
||||
input.gradeId ? getGradeNameById(input.gradeId) : Promise.resolve(null),
|
||||
])
|
||||
return {
|
||||
subjectName: subjectRecord?.name,
|
||||
gradeName: gradeRecord?.name,
|
||||
subjectName,
|
||||
gradeName,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +262,7 @@ export const persistExamDraft = async (input: {
|
||||
const buildOrderedQuestionsFromStructure = (
|
||||
structure: AiGeneratedStructureNode[],
|
||||
generated: AiGeneratedQuestion[]
|
||||
) => {
|
||||
): Array<{ id: string; score: number }> => {
|
||||
const questionById = new Map(generated.map((q) => [q.id, q] as const))
|
||||
const orderedQuestions: Array<{ id: string; score: number }> = []
|
||||
const collectOrder = (nodes: AiGeneratedStructureNode[]) => {
|
||||
@@ -515,21 +508,19 @@ export const getExamPreview = async (
|
||||
|
||||
/**
|
||||
* Get all subjects for exam forms.
|
||||
* Delegates to school module data-access to avoid direct DB queries on subjects table.
|
||||
*/
|
||||
export const getExamSubjects = async (): Promise<Array<{ id: string; name: string }>> => {
|
||||
const allSubjects = await db.query.subjects.findMany({
|
||||
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
|
||||
})
|
||||
const allSubjects = await getSubjectOptions()
|
||||
return allSubjects.map((s) => ({ id: s.id, name: s.name }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all grades for exam forms.
|
||||
* Delegates to school module data-access to avoid direct DB queries on grades table.
|
||||
*/
|
||||
export const getExamGrades = async (): Promise<Array<{ id: string; name: string }>> => {
|
||||
const allGrades = await db.query.grades.findMany({
|
||||
orderBy: (grades, { asc }) => [asc(grades.order), asc(grades.name)],
|
||||
})
|
||||
const allGrades = await getGradeOptions()
|
||||
return allGrades.map((g) => ({ id: g.id, name: g.name }))
|
||||
}
|
||||
|
||||
|
||||
54
src/modules/exams/utils/normalize-structure.ts
Normal file
54
src/modules/exams/utils/normalize-structure.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import type { ExamNode } from "../components/assembly/selected-question-list"
|
||||
|
||||
/**
|
||||
* Normalize raw exam structure data into typed `ExamNode[]`.
|
||||
*
|
||||
* - Validates each node's shape at runtime (type guard pattern, no `as`).
|
||||
* - Ensures every node has a unique id (generates one if missing or duplicate).
|
||||
* - Recursively normalizes group children.
|
||||
* - Returns `[]` for non-array input.
|
||||
*
|
||||
* Used by the exam build page to convert persisted `exam.structure` (unknown
|
||||
* JSON from DB) into a typed tree before passing to `<ExamAssembly />`.
|
||||
*/
|
||||
export function normalizeStructure(nodes: unknown): ExamNode[] {
|
||||
const seen = new Set<string>()
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null
|
||||
|
||||
const normalize = (raw: unknown[]): ExamNode[] => {
|
||||
return raw
|
||||
.map((n): ExamNode | null => {
|
||||
if (!isRecord(n)) return null
|
||||
const type = n.type
|
||||
if (type !== "group" && type !== "question") return null
|
||||
|
||||
let id = typeof n.id === "string" && n.id.length > 0 ? n.id : createId()
|
||||
while (seen.has(id)) id = createId()
|
||||
seen.add(id)
|
||||
|
||||
if (type === "group") {
|
||||
return {
|
||||
id,
|
||||
type: "group",
|
||||
title: typeof n.title === "string" ? n.title : undefined,
|
||||
children: normalize(Array.isArray(n.children) ? n.children : []),
|
||||
} satisfies ExamNode
|
||||
}
|
||||
|
||||
if (typeof n.questionId !== "string" || n.questionId.length === 0) return null
|
||||
|
||||
return {
|
||||
id,
|
||||
type: "question",
|
||||
questionId: n.questionId,
|
||||
score: typeof n.score === "number" ? n.score : undefined,
|
||||
} satisfies ExamNode
|
||||
})
|
||||
.filter((n): n is ExamNode => n !== null)
|
||||
}
|
||||
|
||||
if (!Array.isArray(nodes)) return []
|
||||
return normalize(nodes)
|
||||
}
|
||||
Reference in New Issue
Block a user