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

@@ -1,12 +1,12 @@
"use server";
"use server"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
import { Permissions } from "@/shared/types/permissions";
import { CreateQuestionSchema } from "./schema";
import type { CreateQuestionInput } from "./schema";
import type { ActionState } from "@/shared/types/action-state";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { CreateQuestionSchema } from "./schema"
import type { CreateQuestionInput } from "./schema"
import type { ActionState } from "@/shared/types/action-state"
import { revalidatePath } from "next/cache"
import { z } from "zod"
import {
createQuestionWithRelations,
deleteQuestionByIdRecursive,
@@ -14,69 +14,68 @@ import {
getQuestions,
updateQuestionById,
type GetQuestionsParams,
} from "./data-access";
import type { KnowledgePointOption } from "./types";
} from "./data-access"
import type { KnowledgePointOption } from "./types"
/** Result type of getQuestions (data + meta) */
type QuestionsListResult = Awaited<ReturnType<typeof getQuestions>>;
type QuestionsListResult = Awaited<ReturnType<typeof getQuestions>>
/** Result type of getKnowledgePointOptions */
type KnowledgePointOptionsResult = KnowledgePointOption[];
type KnowledgePointOptionsResult = KnowledgePointOption[]
export async function createNestedQuestion(
export async function createQuestionAction(
prevState: ActionState<string> | undefined,
formData: FormData | CreateQuestionInput
formData: FormData | CreateQuestionInput,
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.QUESTION_CREATE);
const ctx = await requirePermission(Permissions.QUESTION_CREATE)
let rawInput: unknown = formData;
let rawInput: unknown = formData
if (formData instanceof FormData) {
const jsonString = formData.get("json");
if (typeof jsonString === "string") {
rawInput = JSON.parse(jsonString) as unknown;
} else {
return { success: false, message: "Invalid submission format. Expected JSON." };
}
const jsonString = formData.get("json")
if (typeof jsonString === "string") {
rawInput = JSON.parse(jsonString) as unknown
} else {
return { success: false, message: "Invalid submission format. Expected JSON." }
}
}
const validatedFields = CreateQuestionSchema.safeParse(rawInput);
const validatedFields = CreateQuestionSchema.safeParse(rawInput)
if (!validatedFields.success) {
return {
success: false,
message: "Validation failed",
errors: validatedFields.error.flatten().fieldErrors,
};
}
}
const input = validatedFields.data;
const input = validatedFields.data
await createQuestionWithRelations(input, ctx.userId);
await createQuestionWithRelations(input, ctx.userId)
revalidatePath("/teacher/questions");
revalidatePath("/teacher/questions")
return {
success: true,
message: "Question created successfully",
};
}
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
return { success: false, message: e.message }
}
if (e instanceof Error) {
return {
success: false,
message: e.message || "Database error occurred",
};
}
}
return {
success: false,
message: "An unexpected error occurred",
};
}
}
}
@@ -86,90 +85,90 @@ const UpdateQuestionSchema = z.object({
difficulty: z.number().min(1).max(5),
content: z.unknown(),
knowledgePointIds: z.array(z.string()).optional(),
});
})
export async function updateQuestionAction(
prevState: ActionState<string> | undefined,
formData: FormData
formData: FormData,
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.QUESTION_UPDATE);
const canEditAll = ctx.dataScope.type === "all";
const ctx = await requirePermission(Permissions.QUESTION_UPDATE)
const canEditAll = ctx.dataScope.type === "all"
const jsonString = formData.get("json");
const jsonString = formData.get("json")
if (typeof jsonString !== "string") {
return { success: false, message: "Invalid submission format. Expected JSON." };
return { success: false, message: "Invalid submission format. Expected JSON." }
}
const parsed = UpdateQuestionSchema.safeParse(JSON.parse(jsonString));
const parsed = UpdateQuestionSchema.safeParse(JSON.parse(jsonString))
if (!parsed.success) {
return {
success: false,
message: "Validation failed",
errors: parsed.error.flatten().fieldErrors,
};
}
}
const { id, ...updateData } = parsed.data;
const { id, ...updateData } = parsed.data
await updateQuestionById(id, updateData, canEditAll, ctx.userId);
await updateQuestionById(id, updateData, canEditAll, ctx.userId)
revalidatePath("/teacher/questions");
revalidatePath("/teacher/questions")
return { success: true, message: "Question updated successfully" };
return { success: true, message: "Question updated successfully" }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
return { success: false, message: e.message }
}
if (e instanceof Error) {
return { success: false, message: e.message };
return { success: false, message: e.message }
}
return { success: false, message: "An unexpected error occurred" };
return { success: false, message: "An unexpected error occurred" }
}
}
export async function deleteQuestionAction(
prevState: ActionState<string> | undefined,
formData: FormData
formData: FormData,
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.QUESTION_DELETE);
const canDeleteAll = ctx.dataScope.type === "all";
const ctx = await requirePermission(Permissions.QUESTION_DELETE)
const canDeleteAll = ctx.dataScope.type === "all"
const questionId = formData.get("questionId");
const questionId = formData.get("questionId")
if (typeof questionId !== "string") {
return { success: false, message: "Invalid question ID" };
return { success: false, message: "Invalid question ID" }
}
await deleteQuestionByIdRecursive(questionId, canDeleteAll, ctx.userId);
await deleteQuestionByIdRecursive(questionId, canDeleteAll, ctx.userId)
revalidatePath("/teacher/questions");
revalidatePath("/teacher/questions")
return { success: true, message: "Question deleted successfully" };
return { success: true, message: "Question deleted successfully" }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
return { success: false, message: e.message }
}
if (e instanceof Error) {
return { success: false, message: e.message };
return { success: false, message: e.message }
}
return { success: false, message: "Failed to delete question" };
return { success: false, message: "Failed to delete question" }
}
}
export async function getQuestionsAction(
params: GetQuestionsParams
params: GetQuestionsParams,
): Promise<ActionState<QuestionsListResult>> {
try {
await requirePermission(Permissions.QUESTION_READ);
const data = await getQuestions(params);
return { success: true, data };
await requirePermission(Permissions.QUESTION_READ)
const data = await getQuestions(params)
return { success: true, data }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
return { success: false, message: e.message }
}
const message = e instanceof Error ? e.message : "Failed to fetch questions";
return { success: false, message };
const message = e instanceof Error ? e.message : "Failed to fetch questions"
return { success: false, message }
}
}
@@ -177,14 +176,14 @@ export async function getKnowledgePointOptionsAction(): Promise<
ActionState<KnowledgePointOptionsResult>
> {
try {
await requirePermission(Permissions.QUESTION_READ);
const data = await getKnowledgePointOptions();
return { success: true, data };
await requirePermission(Permissions.QUESTION_READ)
const data = await getKnowledgePointOptions()
return { success: true, data }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message };
return { success: false, message: e.message }
}
const message = e instanceof Error ? e.message : "Failed to fetch knowledge point options";
return { success: false, message };
const message = e instanceof Error ? e.message : "Failed to fetch knowledge point options"
return { success: false, message }
}
}

View File

@@ -37,7 +37,7 @@ import {
} from "@/shared/components/ui/select"
import { Textarea } from "@/shared/components/ui/textarea"
import { BaseQuestionSchema } from "../schema"
import { createNestedQuestion, getKnowledgePointOptionsAction, updateQuestionAction } from "../actions"
import { createQuestionAction, getKnowledgePointOptionsAction, updateQuestionAction } from "../actions"
import { toast } from "sonner"
import { KnowledgePointOption, Question } from "../types"
@@ -258,7 +258,7 @@ export function CreateQuestionDialog({
fd.set("json", JSON.stringify(payload))
const res = isEdit
? await updateQuestionAction(undefined, fd)
: await createNestedQuestion(undefined, fd)
: await createQuestionAction(undefined, fd)
if (res.success) {
toast.success(isEdit ? "Updated question" : "Created question")
onOpenChange(false)

View File

@@ -2,9 +2,7 @@
import { useEffect, useState } from "react"
import { useQueryState, parseAsString } from "nuqs"
import { Search, X } from "lucide-react"
import { Input } from "@/shared/components/ui/input"
import {
Select,
SelectContent,
@@ -12,7 +10,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Button } from "@/shared/components/ui/button"
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
import { getKnowledgePointOptionsAction } from "../actions"
import type { KnowledgePointOption } from "../types"
@@ -33,18 +31,30 @@ export function QuestionFilters() {
})
}, [])
const hasFilters = Boolean(
search || type !== "all" || difficulty !== "all" || knowledgePointId !== "all",
)
return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<FilterBar
layout="between"
gapClassName="gap-4"
hasFilters={hasFilters}
onReset={() => {
setSearch(null)
setType(null)
setDifficulty(null)
setKnowledgePointId(null)
}}
>
<div className="flex flex-1 items-center gap-2">
<div className="relative flex-1 md:max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search questions..."
className="pl-8"
value={search}
onChange={(e) => setSearch(e.target.value || null)}
/>
</div>
<FilterSearchInput
value={search}
onChange={(v) => setSearch(v || null)}
placeholder="Search questions..."
className="flex-1 md:max-w-sm"
inputClassName="border-muted-foreground/20 pl-8"
/>
<Select value={type} onValueChange={(val) => setType(val === "all" ? null : val)}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Type" />
@@ -88,23 +98,7 @@ export function QuestionFilters() {
})}
</SelectContent>
</Select>
{(search || type !== "all" || difficulty !== "all" || knowledgePointId !== "all") && (
<Button
variant="ghost"
onClick={() => {
setSearch(null)
setType(null)
setDifficulty(null)
setKnowledgePointId(null)
}}
className="h-8 px-2 lg:px-3"
>
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
</div>
</div>
</FilterBar>
)
}

View File

@@ -1,10 +1,11 @@
import 'server-only';
import { db } from "@/shared/db";
import { chapters, knowledgePoints, questions, questionsToKnowledgePoints, textbooks } from "@/shared/db/schema";
import { and, asc, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm";
import { knowledgePoints, questions, questionsToKnowledgePoints } from "@/shared/db/schema";
import { and, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm";
import { cache } from "react";
import { createId } from "@paralleldrive/cuid2";
import { getKnowledgePointOptions as getKnowledgePointOptionsFromTextbooks } from "@/modules/textbooks/data-access";
import type { CreateQuestionInput } from "./schema";
import type { KnowledgePointOption, Question, QuestionType } from "./types";
@@ -264,38 +265,9 @@ export async function deleteQuestionByIdRecursive(
}
export async function getKnowledgePointOptions(): Promise<KnowledgePointOption[]> {
const rows = await db
.select({
id: knowledgePoints.id,
name: knowledgePoints.name,
chapterId: chapters.id,
chapterTitle: chapters.title,
textbookId: textbooks.id,
textbookTitle: textbooks.title,
subject: textbooks.subject,
grade: textbooks.grade,
})
.from(knowledgePoints)
.leftJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
.leftJoin(textbooks, eq(textbooks.id, chapters.textbookId))
.orderBy(
asc(textbooks.title),
asc(chapters.order),
asc(chapters.title),
asc(knowledgePoints.order),
asc(knowledgePoints.name)
);
return rows.map((row) => ({
id: row.id,
name: row.name,
chapterId: row.chapterId ?? null,
chapterTitle: row.chapterTitle ?? null,
textbookId: row.textbookId ?? null,
textbookTitle: row.textbookTitle ?? null,
subject: row.subject ?? null,
grade: row.grade ?? null,
}));
// Delegate to textbooks module data-access to avoid direct queries on
// textbooks/chapters/knowledgePoints tables (owned by textbooks module).
return await getKnowledgePointOptionsFromTextbooks()
}
// ---------------------------------------------------------------------------