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

@@ -2,6 +2,7 @@
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
import { Permissions } from "@/shared/types/permissions";
import type { ActionState } from "@/shared/types/action-state";
import { revalidatePath } from "next/cache";
import {
createTextbook,
@@ -15,15 +16,20 @@ import {
deleteTextbook,
reorderChapters
} from "./data-access";
import type { CreateTextbookInput, UpdateTextbookInput } from "./types";
import {
CreateTextbookSchema,
UpdateTextbookSchema,
CreateChapterSchema,
UpdateChapterContentSchema,
CreateKnowledgePointSchema,
UpdateKnowledgePointSchema,
} from "./schema";
const getStringValue = (formData: FormData, key: string): string => {
const value = formData.get(key)
return typeof value === "string" ? value : ""
}
// ... existing code ...
export async function reorderChaptersAction(
chapterId: string,
newIndex: number,
@@ -43,36 +49,28 @@ export async function reorderChaptersAction(
}
}
export type ActionState = {
success: boolean;
message?: string;
errors?: Record<string, string[]>;
};
// ... existing createTextbookAction ...
export async function createTextbookAction(
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
// ... implementation same as before
const rawData: CreateTextbookInput = {
const parsed = CreateTextbookSchema.safeParse({
title: getStringValue(formData, "title"),
subject: getStringValue(formData, "subject"),
grade: getStringValue(formData, "grade"),
publisher: getStringValue(formData, "publisher"),
};
});
if (!rawData.title || !rawData.subject || !rawData.grade) {
if (!parsed.success) {
return {
success: false,
message: "Please fill in all required fields.",
errors: parsed.error.flatten().fieldErrors,
};
}
try {
await requirePermission(Permissions.TEXTBOOK_CREATE);
await createTextbook(rawData);
await createTextbook(parsed.data);
revalidatePath("/teacher/textbooks");
return {
success: true,
@@ -94,24 +92,25 @@ export async function updateTextbookAction(
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const rawData: UpdateTextbookInput = {
const parsed = UpdateTextbookSchema.safeParse({
id: textbookId,
title: getStringValue(formData, "title"),
subject: getStringValue(formData, "subject"),
grade: getStringValue(formData, "grade"),
publisher: getStringValue(formData, "publisher"),
};
});
if (!rawData.title || !rawData.subject || !rawData.grade) {
if (!parsed.success) {
return {
success: false,
message: "Please fill in all required fields.",
errors: parsed.error.flatten().fieldErrors,
};
}
try {
await requirePermission(Permissions.TEXTBOOK_UPDATE);
await updateTextbook(rawData);
await updateTextbook(parsed.data);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return {
success: true,
@@ -153,21 +152,27 @@ export async function deleteTextbookAction(
export async function createChapterAction(
textbookId: string,
parentId: string | undefined,
prevState: ActionState | null,
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const title = getStringValue(formData, "title");
if (!title) return { success: false, message: "Title is required" };
const parsed = CreateChapterSchema.safeParse({
textbookId,
title: getStringValue(formData, "title"),
parentId,
order: 0,
});
if (!parsed.success) {
return {
success: false,
message: "Title is required",
errors: parsed.error.flatten().fieldErrors,
};
}
try {
await requirePermission(Permissions.TEXTBOOK_CREATE);
await createChapter({
textbookId,
title,
parentId,
order: 0 // Default order
});
await createChapter(parsed.data);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Chapter created successfully" };
} catch (e) {
@@ -183,9 +188,22 @@ export async function updateChapterContentAction(
content: string,
textbookId: string
): Promise<ActionState> {
const parsed = UpdateChapterContentSchema.safeParse({
chapterId,
content,
});
if (!parsed.success) {
return {
success: false,
message: "Invalid chapter content data",
errors: parsed.error.flatten().fieldErrors,
};
}
try {
await requirePermission(Permissions.TEXTBOOK_UPDATE);
await updateChapterContent({ chapterId, content });
await updateChapterContent(parsed.data);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Content updated successfully" };
} catch (e) {
@@ -219,15 +237,24 @@ export async function createKnowledgePointAction(
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const name = getStringValue(formData, "name");
const description = getStringValue(formData, "description");
const anchorText = getStringValue(formData, "anchorText");
const parsed = CreateKnowledgePointSchema.safeParse({
name: getStringValue(formData, "name"),
description: getStringValue(formData, "description") || undefined,
anchorText: getStringValue(formData, "anchorText") || undefined,
chapterId,
});
if (!name) return { success: false, message: "Name is required" };
if (!parsed.success) {
return {
success: false,
message: "Name is required",
errors: parsed.error.flatten().fieldErrors,
};
}
try {
await requirePermission(Permissions.TEXTBOOK_CREATE);
await createKnowledgePoint({ name, description, anchorText, chapterId });
await createKnowledgePoint(parsed.data);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Knowledge point created successfully" };
} catch (e) {
@@ -261,15 +288,24 @@ export async function updateKnowledgePointAction(
prevState: ActionState | null,
formData: FormData
): Promise<ActionState> {
const name = getStringValue(formData, "name");
const description = getStringValue(formData, "description");
const anchorText = getStringValue(formData, "anchorText");
const parsed = UpdateKnowledgePointSchema.safeParse({
id: kpId,
name: getStringValue(formData, "name"),
description: getStringValue(formData, "description") || undefined,
anchorText: getStringValue(formData, "anchorText") || undefined,
});
if (!name) return { success: false, message: "Name is required" };
if (!parsed.success) {
return {
success: false,
message: "Name is required",
errors: parsed.error.flatten().fieldErrors,
};
}
try {
await requirePermission(Permissions.TEXTBOOK_UPDATE);
await updateKnowledgePoint({ id: kpId, name, description, anchorText });
await updateKnowledgePoint(parsed.data);
revalidatePath(`/teacher/textbooks/${textbookId}`);
return { success: true, message: "Knowledge point updated successfully" };
} catch (e) {

View File

@@ -1,10 +1,7 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import { Search, X } from "lucide-react"
import { Input } from "@/shared/components/ui/input"
import { Button } from "@/shared/components/ui/button"
import {
Select,
SelectContent,
@@ -12,6 +9,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
export function TextbookFilters() {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
@@ -21,16 +19,20 @@ export function TextbookFilters() {
const hasFilters = Boolean(search || subject !== "all" || grade !== "all")
return (
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<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 by title, publisher..."
className="pl-9 bg-background border-muted-foreground/20"
value={search}
onChange={(e) => setSearch(e.target.value || null)}
/>
</div>
<FilterBar
layout="between"
hasFilters={hasFilters}
onReset={() => {
setSearch(null)
setSubject(null)
setGrade(null)
}}
>
<FilterSearchInput
value={search}
onChange={(v) => setSearch(v || null)}
placeholder="Search by title, publisher..."
/>
<div className="flex flex-wrap gap-2 w-full md:w-auto">
<Select value={subject} onValueChange={(val) => setSubject(val === "all" ? null : val)}>
@@ -63,23 +65,7 @@ export function TextbookFilters() {
<SelectItem value="Grade 12">Grade 12</SelectItem>
</SelectContent>
</Select>
{hasFilters && (
<Button
variant="ghost"
onClick={() => {
setSearch(null)
setSubject(null)
setGrade(null)
}}
className="h-10 px-3 text-muted-foreground hover:text-foreground"
>
Reset
<X className="ml-2 h-4 w-4" />
</Button>
)}
</div>
</div>
</FilterBar>
)
}

View File

@@ -8,21 +8,25 @@ import { db } from "@/shared/db"
import { chapters, knowledgePoints, textbooks } from "@/shared/db/schema"
import type {
Chapter,
CreateChapterInput,
CreateTextbookInput,
KnowledgePoint,
Textbook,
UpdateChapterContentInput,
UpdateTextbookInput,
} from "./types"
import type {
CreateChapterInput,
CreateKnowledgePointInput,
CreateTextbookInput,
UpdateChapterContentInput,
UpdateKnowledgePointInput,
UpdateTextbookInput,
} from "./schema"
const normalizeOptional = (v: string | null | undefined) => {
const normalizeOptional = (v: string | null | undefined): string | null => {
const trimmed = v?.trim()
if (!trimmed) return null
return trimmed
}
const sortChapters = (a: Chapter, b: Chapter) => {
const sortChapters = (a: Chapter, b: Chapter): number => {
const ao = a.order ?? 0
const bo = b.order ?? 0
if (ao !== bo) return ao - bo
@@ -308,10 +312,11 @@ export async function deleteChapter(id: string): Promise<void> {
}
const idsToDelete: string[] = []
const stack = [id]
const stack: string[] = [id]
const seen = new Set<string>()
while (stack.length) {
const cur = stack.pop()!
const cur = stack.pop()
if (!cur) break
if (seen.has(cur)) continue
seen.add(cur)
idsToDelete.push(cur)
@@ -376,7 +381,7 @@ export const getKnowledgePointsByTextbookId = cache(async (textbookId: string):
}))
})
export async function createKnowledgePoint(data: { name: string; description?: string; anchorText?: string; chapterId?: string; parentId?: string }): Promise<void> {
export async function createKnowledgePoint(data: CreateKnowledgePointInput): Promise<void> {
await db.insert(knowledgePoints).values({
id: createId(),
name: data.name,
@@ -389,7 +394,7 @@ export async function createKnowledgePoint(data: { name: string; description?: s
})
}
export async function updateKnowledgePoint(data: { id: string; name: string; description?: string; anchorText?: string }): Promise<void> {
export async function updateKnowledgePoint(data: UpdateKnowledgePointInput): Promise<void> {
await db
.update(knowledgePoints)
.set({
@@ -453,3 +458,57 @@ export const getTextbooksDashboardStats = cache(async (): Promise<TextbooksDashb
chapterCount: Number(chapterCountRow[0]?.value ?? 0),
}
})
// ---------------------------------------------------------------------------
// Cross-module query interfaces — read-only access for other modules
// ---------------------------------------------------------------------------
export type KnowledgePointOption = {
id: string
name: string
chapterId: string | null
chapterTitle: string | null
textbookId: string | null
textbookTitle: string | null
subject: string | null
grade: string | null
}
/**
* 获取所有知识点选项(含章节和教材信息)。
* 供 questions 模块跨模块调用使用,避免直接查询 textbooks/chapters/knowledgePoints 表。
*/
export const getKnowledgePointOptions = cache(async (): 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,
}))
})

View File

@@ -0,0 +1,64 @@
import { z } from "zod"
export const CreateTextbookSchema = z.object({
title: z.string().min(1).max(255),
subject: z.string().min(1).max(100),
grade: z.string().min(1).max(100),
publisher: z.string().max(255).optional().default(""),
})
export type CreateTextbookInput = z.infer<typeof CreateTextbookSchema>
export const UpdateTextbookSchema = z.object({
id: z.string().min(1),
title: z.string().min(1).max(255),
subject: z.string().min(1).max(100),
grade: z.string().min(1).max(100),
publisher: z.string().max(255).optional().default(""),
})
export type UpdateTextbookInput = z.infer<typeof UpdateTextbookSchema>
export const CreateChapterSchema = z.object({
textbookId: z.string().min(1),
title: z.string().min(1).max(255),
parentId: z.string().optional(),
order: z.coerce.number().int().min(0).optional(),
})
export type CreateChapterInput = z.infer<typeof CreateChapterSchema>
export const UpdateChapterContentSchema = z.object({
chapterId: z.string().min(1),
content: z.string(),
})
export type UpdateChapterContentInput = z.infer<typeof UpdateChapterContentSchema>
export const CreateKnowledgePointSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().optional(),
anchorText: z.string().optional(),
chapterId: z.string().optional(),
parentId: z.string().optional(),
})
export type CreateKnowledgePointInput = z.infer<typeof CreateKnowledgePointSchema>
export const UpdateKnowledgePointSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(255),
description: z.string().optional(),
anchorText: z.string().optional(),
})
export type UpdateKnowledgePointInput = z.infer<typeof UpdateKnowledgePointSchema>
export const ReorderChaptersSchema = z.object({
chapterId: z.string().min(1),
newIndex: z.coerce.number().int().min(0),
parentId: z.string().nullable(),
textbookId: z.string().min(1),
})
export type ReorderChaptersInput = z.infer<typeof ReorderChaptersSchema>

View File

@@ -1,7 +1,11 @@
// Define types based on Drizzle Schema
// In a real app, we would infer these from the schema, but since we might not have the full schema setup running locally with DB,
// In a real app, we would infer these from the schema, but since we might not have the full schema setup running locally with DB,
// we will define interfaces that match the schema description in ARCHITECTURE.md and schema.ts
// Note: Input types (CreateTextbookInput, UpdateTextbookInput, CreateChapterInput,
// UpdateChapterContentInput, CreateKnowledgePointInput, UpdateKnowledgePointInput)
// are defined in ./schema.ts alongside their Zod validation schemas.
export type Textbook = {
id: string;
title: string;
@@ -29,21 +33,6 @@ export type Chapter = {
children?: Chapter[];
};
export type CreateTextbookInput = {
title: string;
subject: string;
grade: string;
publisher: string;
};
export type UpdateTextbookInput = {
id: string;
title: string;
subject: string;
grade: string;
publisher: string;
};
export type KnowledgePoint = {
id: string;
name: string;
@@ -54,30 +43,3 @@ export type KnowledgePoint = {
level: number;
order: number;
};
export type CreateChapterInput = {
textbookId: string;
title: string;
parentId?: string;
order?: number;
};
export type UpdateChapterContentInput = {
chapterId: string;
content: string;
};
export type CreateKnowledgePointInput = {
name: string;
description?: string;
anchorText?: string;
parentId?: string;
chapterId?: string;
};
export type UpdateKnowledgePointInput = {
id: string;
name: string;
description?: string;
anchorText?: string;
};