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

主要变更:

- 新增 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:
SpecialX
2026-06-22 01:06:16 +08:00
parent d8962aba96
commit 978d9a8309
327 changed files with 34070 additions and 5642 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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