From 2197e6806910097d8560c41721da85844ca7f414 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:37:19 +0800 Subject: [PATCH] feat(lesson-preparation): add anchor canvas design, new blocks, and textbook content node - Add anchor injector for canvas-based anchor positioning - Add new block components: blackboard, homework, import, key-point, new-teaching, objective, summary - Add textbook content node for React Flow canvas - Update actions (kp, publish, main), data-access (templates, versions, main) - Update editor, node-editor, block-renderer, and picker components - Update schema, types, hooks, and lib utilities (document-migration, node-summary, rf-mappers) --- src/modules/lesson-preparation/actions-kp.ts | 14 +- .../lesson-preparation/actions-publish.ts | 29 +- src/modules/lesson-preparation/actions.ts | 112 +++-- .../components/block-renderer.tsx | 39 +- .../components/blocks/blackboard-block.tsx | 84 ++++ .../components/blocks/homework-block.tsx | 92 ++++ .../components/blocks/import-block.tsx | 70 +++ .../components/blocks/key-point-block.tsx | 82 ++++ .../components/blocks/new-teaching-block.tsx | 119 +++++ .../components/blocks/objective-block.tsx | 87 ++++ .../components/blocks/reflection-block.tsx | 82 +++- .../components/blocks/summary-block.tsx | 78 ++++ .../components/knowledge-point-picker.tsx | 43 +- .../components/lesson-plan-card.tsx | 42 +- .../components/lesson-plan-editor.tsx | 84 +++- .../components/lesson-plan-filters.tsx | 12 +- .../components/lesson-plan-list.tsx | 52 ++- .../components/node-editor.tsx | 127 ++++- .../nodes/textbook-content-node.tsx | 440 ++++++++++++++++++ .../components/publish-homework-dialog.tsx | 36 +- .../components/question-bank-picker.tsx | 97 ++-- .../components/template-picker.tsx | 228 ++++++++- .../components/version-history-drawer.tsx | 37 +- .../config/block-registry.tsx | 123 +++-- .../data-access-templates.ts | 10 +- .../data-access-versions.ts | 71 +-- src/modules/lesson-preparation/data-access.ts | 104 ++++- .../hooks/use-lesson-plan-editor.ts | 173 ++++++- .../lesson-preparation/lib/anchor-injector.ts | 304 ++++++++++++ .../lib/document-migration.ts | 275 ++++++++++- .../lesson-preparation/lib/node-summary.ts | 78 +++- .../lesson-preparation/lib/rf-mappers.ts | 151 ++++-- src/modules/lesson-preparation/schema.ts | 30 +- src/modules/lesson-preparation/types.ts | 187 +++++++- 34 files changed, 3190 insertions(+), 402 deletions(-) create mode 100644 src/modules/lesson-preparation/components/blocks/blackboard-block.tsx create mode 100644 src/modules/lesson-preparation/components/blocks/homework-block.tsx create mode 100644 src/modules/lesson-preparation/components/blocks/import-block.tsx create mode 100644 src/modules/lesson-preparation/components/blocks/key-point-block.tsx create mode 100644 src/modules/lesson-preparation/components/blocks/new-teaching-block.tsx create mode 100644 src/modules/lesson-preparation/components/blocks/objective-block.tsx create mode 100644 src/modules/lesson-preparation/components/blocks/summary-block.tsx create mode 100644 src/modules/lesson-preparation/components/nodes/textbook-content-node.tsx create mode 100644 src/modules/lesson-preparation/lib/anchor-injector.ts diff --git a/src/modules/lesson-preparation/actions-kp.ts b/src/modules/lesson-preparation/actions-kp.ts index b87f540..3b5b8c5 100644 --- a/src/modules/lesson-preparation/actions-kp.ts +++ b/src/modules/lesson-preparation/actions-kp.ts @@ -7,6 +7,7 @@ import { getKnowledgePointsByTextbookId, getKnowledgePointsByChapterId, } from "@/modules/textbooks/data-access"; +import { getKnowledgePointOptionsSchema } from "./schema"; import type { ActionState } from "./types"; // 加载知识点选项(供客户端知识点选择器使用) @@ -18,14 +19,19 @@ export async function getKnowledgePointOptionsAction(input: { > { const t = await getTranslations("lessonPreparation"); try { + const parsed = getKnowledgePointOptionsSchema.safeParse(input); + if (!parsed.success) { + return { success: false, errors: parsed.error.flatten().fieldErrors }; + } + await requirePermission(Permissions.LESSON_PLAN_READ); - if (!input.textbookId) return { success: true, data: { options: [] } }; + if (!parsed.data.textbookId) return { success: true, data: { options: [] } }; let kps; - if (input.chapterId) { - kps = await getKnowledgePointsByChapterId(input.chapterId); + if (parsed.data.chapterId) { + kps = await getKnowledgePointsByChapterId(parsed.data.chapterId); } else { - kps = await getKnowledgePointsByTextbookId(input.textbookId); + kps = await getKnowledgePointsByTextbookId(parsed.data.textbookId); } return { success: true, diff --git a/src/modules/lesson-preparation/actions-publish.ts b/src/modules/lesson-preparation/actions-publish.ts index d64b1cf..7ac5e72 100644 --- a/src/modules/lesson-preparation/actions-publish.ts +++ b/src/modules/lesson-preparation/actions-publish.ts @@ -4,10 +4,11 @@ import { revalidatePath } from "next/cache"; import { getTranslations } from "next-intl/server"; import { requirePermission, - PermissionDeniedError, } from "@/shared/lib/auth-guard"; import { Permissions } from "@/shared/types/permissions"; +import { handleActionError, safeParseDate } from "@/shared/lib/action-utils"; import { publishLessonPlanHomework, PublishServiceError } from "./publish-service"; +import { publishLessonPlanHomeworkSchema } from "./schema"; import type { ActionState } from "./types"; export async function publishLessonPlanHomeworkAction(input: { @@ -19,19 +20,26 @@ export async function publishLessonPlanHomeworkAction(input: { }): Promise> { const t = await getTranslations("lessonPreparation"); try { + const parsed = publishLessonPlanHomeworkSchema.safeParse(input); + if (!parsed.success) { + return { success: false, errors: parsed.error.flatten().fieldErrors }; + } + const ctx = await requirePermission( Permissions.LESSON_PLAN_PUBLISH, ); await requirePermission(Permissions.HOMEWORK_CREATE); const result = await publishLessonPlanHomework({ - planId: input.planId, - blockId: input.blockId, + planId: parsed.data.planId, + blockId: parsed.data.blockId, userId: ctx.userId, - classIds: input.classIds, - availableAt: input.availableAt - ? new Date(input.availableAt) + classIds: parsed.data.classIds, + availableAt: parsed.data.availableAt + ? safeParseDate(parsed.data.availableAt, "可用时间") + : undefined, + dueAt: parsed.data.dueAt + ? safeParseDate(parsed.data.dueAt, "截止时间") : undefined, - dueAt: input.dueAt ? new Date(input.dueAt) : undefined, }); revalidatePath("/teacher/lesson-plans"); revalidatePath("/teacher/homework"); @@ -43,17 +51,12 @@ export async function publishLessonPlanHomeworkAction(input: { }, }; } catch (e) { - if (e instanceof PermissionDeniedError) - return { success: false, message: e.message }; // publish-service 抛出 PublishServiceError(含 code),翻译为 i18n 消息 if (e instanceof PublishServiceError) { const messageKey = PUBLISH_ERROR_KEY_MAP[e.code] ?? "error.publish"; return { success: false, message: t(messageKey) }; } - return { - success: false, - message: e instanceof Error ? e.message : t("error.publish"), - }; + return handleActionError(e); } } diff --git a/src/modules/lesson-preparation/actions.ts b/src/modules/lesson-preparation/actions.ts index 7e60717..bf077b7 100644 --- a/src/modules/lesson-preparation/actions.ts +++ b/src/modules/lesson-preparation/actions.ts @@ -2,8 +2,9 @@ import { revalidatePath } from "next/cache"; import { getTranslations } from "next-intl/server"; -import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"; +import { requirePermission } from "@/shared/lib/auth-guard"; import { Permissions } from "@/shared/types/permissions"; +import { handleActionError } from "@/shared/lib/action-utils"; import { getLessonPlans, getLessonPlanById, @@ -11,6 +12,8 @@ import { updateLessonPlanContent, softDeleteLessonPlan, duplicateLessonPlan, + getTextbooksForPicker, + getChaptersForPicker, LessonPlanDataError, } from "./data-access"; import { @@ -45,15 +48,12 @@ export async function getLessonPlansAction(params: { items: Awaited>; }> > { - const t = await getTranslations("lessonPreparation"); try { const ctx = await requirePermission(Permissions.LESSON_PLAN_READ); const items = await getLessonPlans(params, ctx.dataScope, ctx.userId); return { success: true, data: { items } }; } catch (e) { - if (e instanceof PermissionDeniedError) - return { success: false, message: e.message }; - return { success: false, message: t("error.getList") }; + return handleActionError(e); } } @@ -63,16 +63,13 @@ export async function getLessonPlanByIdAction( ): Promise< ActionState<{ plan: Awaited> }> > { - const t = await getTranslations("lessonPreparation"); try { const ctx = await requirePermission(Permissions.LESSON_PLAN_READ); const plan = await getLessonPlanById(planId, ctx.userId); - if (!plan) return { success: false, message: t("error.notFound") }; + if (!plan) return { success: false, message: "课案不存在" }; return { success: true, data: { plan } }; } catch (e) { - if (e instanceof PermissionDeniedError) - return { success: false, message: e.message }; - return { success: false, message: t("error.getOne") }; + return handleActionError(e); } } @@ -107,6 +104,8 @@ export async function createLessonPlanAction( } if (key.startsWith("template.blocks.")) { const parts = key.split("."); + // 期望格式:template.blocks.{templateId}.{blockIndex} + if (parts.length < 4) return key; const templateId = parts[2]; const blockIndex = parts[3]; return t(`template.blocks.${templateId}.${blockIndex}`); @@ -117,11 +116,9 @@ export async function createLessonPlanAction( revalidatePath("/teacher/lesson-plans"); return { success: true, data: { planId } }; } catch (e) { - if (e instanceof PermissionDeniedError) - return { success: false, message: e.message }; if (e instanceof LessonPlanDataError && e.code === "TEMPLATE_NOT_FOUND") return { success: false, message: t("error.templateNotFound") }; - return { success: false, message: t("error.create") }; + return handleActionError(e); } } @@ -131,7 +128,6 @@ export async function updateLessonPlanAction(input: { title?: string; content: LessonPlanDocument; }): Promise { - const t = await getTranslations("lessonPreparation"); try { const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE); const parsed = updateLessonPlanContentSchema.safeParse(input); @@ -139,13 +135,13 @@ export async function updateLessonPlanAction(input: { return { success: false, errors: parsed.error.flatten().fieldErrors }; await updateLessonPlanContent(parsed.data.planId, ctx.userId, { ...(parsed.data.title ? { title: parsed.data.title } : {}), - content: parsed.data.content as LessonPlanDocument, + // 从 unknown 转换:Zod 已校验 content 是对象,具体结构由 LessonPlanDocument 类型守卫 + content: parsed.data.content as unknown as LessonPlanDocument, }); + revalidatePath("/teacher/lesson-plans"); return { success: true }; } catch (e) { - if (e instanceof PermissionDeniedError) - return { success: false, message: e.message }; - return { success: false, message: t("error.save") }; + return handleActionError(e); } } @@ -155,7 +151,6 @@ export async function saveLessonPlanVersionAction(input: { content: LessonPlanDocument; label?: string; }): Promise> { - const t = await getTranslations("lessonPreparation"); try { const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE); const parsed = saveVersionSchema.safeParse(input); @@ -171,9 +166,7 @@ export async function saveLessonPlanVersionAction(input: { await pruneAutoVersions(parsed.data.planId); return { success: true, data: { versionNo } }; } catch (e) { - if (e instanceof PermissionDeniedError) - return { success: false, message: e.message }; - return { success: false, message: t("error.saveVersion") }; + return handleActionError(e); } } @@ -185,15 +178,12 @@ export async function getLessonPlanVersionsAction( versions: Awaited>; }> > { - const t = await getTranslations("lessonPreparation"); try { const ctx = await requirePermission(Permissions.LESSON_PLAN_READ); const versions = await getLessonPlanVersions(planId, ctx.userId); return { success: true, data: { versions } }; } catch (e) { - if (e instanceof PermissionDeniedError) - return { success: false, message: e.message }; - return { success: false, message: t("error.getVersions") }; + return handleActionError(e); } } @@ -202,7 +192,6 @@ export async function revertLessonPlanVersionAction(input: { planId: string; versionNo: number; }): Promise> { - const t = await getTranslations("lessonPreparation"); try { const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE); const parsed = revertVersionSchema.safeParse(input); @@ -213,13 +202,11 @@ export async function revertLessonPlanVersionAction(input: { parsed.data.versionNo, ctx.userId, ); - if (!result) return { success: false, message: t("error.versionNotFound") }; + if (!result) return { success: false, message: "版本不存在" }; revalidatePath(`/teacher/lesson-plans/${parsed.data.planId}/edit`); return { success: true, data: { newVersionNo: result.newVersionNo } }; } catch (e) { - if (e instanceof PermissionDeniedError) - return { success: false, message: e.message }; - return { success: false, message: t("error.revert") }; + return handleActionError(e); } } @@ -227,16 +214,13 @@ export async function revertLessonPlanVersionAction(input: { export async function deleteLessonPlanAction( planId: string, ): Promise { - const t = await getTranslations("lessonPreparation"); try { const ctx = await requirePermission(Permissions.LESSON_PLAN_DELETE); await softDeleteLessonPlan(planId, ctx.userId); revalidatePath("/teacher/lesson-plans"); return { success: true }; } catch (e) { - if (e instanceof PermissionDeniedError) - return { success: false, message: e.message }; - return { success: false, message: t("error.delete") }; + return handleActionError(e); } } @@ -251,11 +235,9 @@ export async function duplicateLessonPlanAction( revalidatePath("/teacher/lesson-plans"); return { success: true, data: { newPlanId } }; } catch (e) { - if (e instanceof PermissionDeniedError) - return { success: false, message: e.message }; if (e instanceof LessonPlanDataError && e.code === "NOT_FOUND") return { success: false, message: t("error.notFound") }; - return { success: false, message: t("error.duplicate") }; + return handleActionError(e); } } @@ -265,15 +247,12 @@ export async function getLessonPlanTemplatesAction(): Promise< templates: Awaited>; }> > { - const t = await getTranslations("lessonPreparation"); try { const ctx = await requirePermission(Permissions.LESSON_PLAN_READ); const templates = await getLessonPlanTemplates(ctx.userId); return { success: true, data: { templates } }; } catch (e) { - if (e instanceof PermissionDeniedError) - return { success: false, message: e.message }; - return { success: false, message: t("error.getTemplates") }; + return handleActionError(e); } } @@ -294,11 +273,9 @@ export async function saveAsTemplateAction(input: { }); return { success: true, data: { templateId } }; } catch (e) { - if (e instanceof PermissionDeniedError) - return { success: false, message: e.message }; if (e instanceof LessonPlanDataError && e.code === "NOT_FOUND") return { success: false, message: t("error.notFound") }; - return { success: false, message: t("error.saveTemplate") }; + return handleActionError(e); } } @@ -306,14 +283,51 @@ export async function saveAsTemplateAction(input: { export async function deleteTemplateAction( templateId: string, ): Promise { - const t = await getTranslations("lessonPreparation"); try { const ctx = await requirePermission(Permissions.LESSON_PLAN_DELETE); await deletePersonalTemplate(templateId, ctx.userId); + revalidatePath("/teacher/lesson-plans"); return { success: true }; } catch (e) { - if (e instanceof PermissionDeniedError) - return { success: false, message: e.message }; - return { success: false, message: t("error.deleteTemplate") }; + return handleActionError(e); + } +} + +// ---- 获取教材列表(供 picker 使用)---- +export async function getTextbooksForPickerAction(): Promise< + ActionState<{ + textbooks: { id: string; title: string; subject: string; grade: string | null }[]; + }> +> { + try { + await requirePermission(Permissions.LESSON_PLAN_READ); + const textbooks = await getTextbooksForPicker(); + return { success: true, data: { textbooks } }; + } catch (e) { + return handleActionError(e); + } +} + +// ---- 获取章节列表(供 picker 使用)---- +export async function getChaptersForPickerAction( + textbookId: string, +): Promise< + ActionState<{ + chapters: { + id: string; + title: string; + parentId: string | null; + order: number | null; + content?: string | null; + children?: unknown[]; + }[]; + }> +> { + try { + await requirePermission(Permissions.LESSON_PLAN_READ); + const chapters = await getChaptersForPicker(textbookId); + return { success: true, data: { chapters } }; + } catch (e) { + return handleActionError(e); } } diff --git a/src/modules/lesson-preparation/components/block-renderer.tsx b/src/modules/lesson-preparation/components/block-renderer.tsx index 01db782..1075234 100644 --- a/src/modules/lesson-preparation/components/block-renderer.tsx +++ b/src/modules/lesson-preparation/components/block-renderer.tsx @@ -27,7 +27,7 @@ import { RichTextBlock } from "./blocks/rich-text-block"; import { ExerciseBlock } from "./blocks/exercise-block"; import { TextStudyBlock } from "./blocks/text-study-block"; import { ReflectionBlock } from "./blocks/reflection-block"; -import type { LessonPlanNode, RichTextBlockData, ExerciseBlockData, TextStudyBlockData } from "../types"; +import type { LessonPlanNode, RichTextBlockData, ExerciseBlockData, TextStudyBlockData, ReflectionBlockData } from "../types"; interface BlockRendererProps { textbookId?: string; @@ -122,7 +122,7 @@ function SortableBlock({ /> ) : node.type === "reflection" ? ( updateNode(node.id, { data: d })} /> ) : ( @@ -140,7 +140,7 @@ export function BlockRenderer({ chapterId, classes, }: BlockRendererProps) { - const { doc } = useLessonPlanEditor(); + const { doc, updateNode } = useLessonPlanEditor(); function onDragEnd(e: DragEndEvent) { const { active, over } = e; @@ -149,11 +149,10 @@ export function BlockRenderer({ const oldIndex = doc.nodes.findIndex((b) => b.id === active.id); const newIndex = doc.nodes.findIndex((b) => b.id === over.id); if (oldIndex === -1 || newIndex === -1) return; - // 交换 order - const nodes = [...doc.nodes]; - const tmpOrder = nodes[oldIndex].order; - nodes[oldIndex].order = nodes[newIndex].order; - nodes[newIndex].order = tmpOrder; + // 交换 order 并写回 store(修复 onDragEnd 未回写 store 的 BUG) + const tmpOrder = doc.nodes[oldIndex].order; + updateNode(doc.nodes[oldIndex].id, { order: doc.nodes[newIndex].order }); + updateNode(doc.nodes[newIndex].id, { order: tmpOrder }); } return ( @@ -163,17 +162,19 @@ export function BlockRenderer({ strategy={verticalListSortingStrategy} >
- {doc.nodes.map((b, i) => ( - - ))} + {doc.nodes + .filter((b): b is LessonPlanNode => b.type !== "textbook_content") + .map((b, i) => ( + + ))}
diff --git a/src/modules/lesson-preparation/components/blocks/blackboard-block.tsx b/src/modules/lesson-preparation/components/blocks/blackboard-block.tsx new file mode 100644 index 0000000..6e6c310 --- /dev/null +++ b/src/modules/lesson-preparation/components/blocks/blackboard-block.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { Tag } from "lucide-react"; +import type { BlackboardBlockData } from "../../types"; +import { KnowledgePointPicker } from "../knowledge-point-picker"; + +interface Props { + data: BlackboardBlockData; + textbookId?: string; + chapterId?: string; + onUpdate: (data: BlackboardBlockData) => void; +} + +const LAYOUTS: BlackboardBlockData["layout"][] = ["structure", "mindmap", "text"]; + +export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props) { + const t = useTranslations("lessonPreparation"); + const [showKpPicker, setShowKpPicker] = useState(false); + + return ( +
+
+ {t("blackboard.hint")} +
+
+ + +
+
+ +