# 备课模块(lesson-preparation)实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 实现 P0(课案地基:数据模型+模板+Block编辑器+版本管理+我的课案库)与 P1(联动:知识点标注+题目创建/拉取+作业发布闭环),构成"备课→出题→下发"最小可用闭环。 **Architecture:** 新建 `src/modules/lesson-preparation/` 模块,严格三层架构(actions→data-access→shared),零跨模块直查。Block 文档以 JSON 存于 `lesson_plans.content`,版本快照存于 `lesson_plan_versions`。作业发布复用 exam 中转(课案练习→exam 草稿→homework assignment),零 schema 侵入。 **Tech Stack:** Next.js 16 App Router · Drizzle ORM (MySQL) · Tiptap(富文本 block)· @dnd-kit(拖拽排序)· zustand(编辑器状态)· react-hook-form + zod · @paralleldrive/cuid2 · vitest **Spec:** `docs/feature/f_bk_design.md` --- ## 文件结构总览 ### 新建文件 ``` src/shared/db/schema.ts # 修改:新增 3 张表 src/shared/types/permissions.ts # 修改:新增 5 个权限点 src/shared/lib/permissions.ts # 修改:teacher/admin 角色映射 src/modules/lesson-preparation/ ├─ types.ts # Block 联合类型 + 课案/版本/模板类型 ├─ constants.ts # block 类型枚举 + 状态常量 + 预置模板定义 ├─ schema.ts # Zod 校验 ├─ data-access.ts # 课案 CRUD ├─ data-access-versions.ts # 版本快照 ├─ data-access-templates.ts # 模板 CRUD ├─ data-access-knowledge.ts # 知识点/题目反查(P1) ├─ actions.ts # Server Actions(P0 基础 + 模板) ├─ actions-publish.ts # 发布作业 Action(P1) ├─ actions-ai.ts # AI 推荐 Action(P1) ├─ publish-service.ts # 发布编排(P1) ├─ ai-suggest.ts # AI 知识点推荐(P1) ├─ seed-templates.ts # 系统预设模板 seed ├─ hooks/ │ └─ use-lesson-plan-editor.ts # 编辑器状态(zustand) └─ components/ ├─ lesson-plan-list.tsx # 我的课案库列表 ├─ lesson-plan-card.tsx # 课案卡片 ├─ lesson-plan-filters.tsx # 筛选器 ├─ lesson-plan-editor.tsx # 编辑器主壳 ├─ block-renderer.tsx # block 分发渲染 ├─ blocks/ │ ├─ rich-text-block.tsx # 富文本类 block │ ├─ text-study-block.tsx # 文本研习画布(P1) │ ├─ exercise-block.tsx # 练习/作业 block(P1) │ └─ reflection-block.tsx # 教学反思(P1 简单渲染) ├─ template-picker.tsx # 模板选择器 ├─ version-history-drawer.tsx # 版本历史 ├─ knowledge-point-picker.tsx # 知识点选择器(P1) ├─ question-bank-picker.tsx # 题库拉取侧栏(P1) ├─ inline-question-editor.tsx # 课案内新建题目(P1) └─ publish-homework-dialog.tsx # 发布作业弹窗(P1) src/app/(dashboard)/teacher/lesson-plans/ ├─ page.tsx # 课案库列表页 ├─ new/page.tsx # 新建课案(选模板) └─ [planId]/edit/page.tsx # 编辑器页 src/modules/layout/config/navigation.ts # 修改:teacher 菜单加"备课" scripts/seed.ts # 修改:调用 seed-templates ``` --- ## 阶段划分 - **阶段 A(P0 地基)**:Task 1-10,先落地,构成可用的课案编辑器 - **阶段 B(P1 联动)**:Task 11-18,在 P0 基础上接通题库与作业发布 --- # 阶段 A:P0 地基 ## Task 1:数据模型 — 新增 3 张表 **Files:** - Modify: `src/shared/db/schema.ts`(末尾追加) - [ ] **Step 1: 在 schema.ts 末尾追加 3 张表定义** ```typescript // --- Lesson Preparation (备课) --- export const lessonPlans = mysqlTable("lesson_plans", { id: id("id").primaryKey(), title: varchar("title", { length: 255 }).notNull(), textbookId: varchar("textbook_id", { length: 128 }).references(() => textbooks.id), chapterId: varchar("chapter_id", { length: 128 }).references(() => chapters.id), coursePlanItemId: varchar("course_plan_item_id", { length: 128 }), subjectId: varchar("subject_id", { length: 128 }).references(() => subjects.id), gradeId: varchar("grade_id", { length: 128 }).references(() => grades.id), templateId: varchar("template_id", { length: 128 }), templateName: varchar("template_name", { length: 100 }), content: json("content").notNull(), status: varchar("status", { length: 50 }).default("draft").notNull(), creatorId: varchar("creator_id", { length: 128 }).notNull().references(() => users.id), lastSavedAt: timestamp("last_saved_at"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), }, (table) => ({ creatorIdx: index("lp_creator_idx").on(table.creatorId), statusIdx: index("lp_status_idx").on(table.status), textbookChapterIdx: index("lp_textbook_chapter_idx").on(table.textbookId, table.chapterId), subjectGradeIdx: index("lp_subject_grade_idx").on(table.subjectId, table.gradeId), })); export const lessonPlanVersions = mysqlTable("lesson_plan_versions", { id: id("id").primaryKey(), planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }), versionNo: int("version_no").notNull(), label: varchar("label", { length: 100 }), content: json("content").notNull(), isAuto: boolean("is_auto").default(false).notNull(), creatorId: varchar("creator_id", { length: 128 }).notNull().references(() => users.id), createdAt: timestamp("created_at").defaultNow().notNull(), }, (table) => ({ planVersionIdx: uniqueIndex("lpv_plan_version_idx").on(table.planId, table.versionNo), planCreatedIdx: index("lpv_plan_created_idx").on(table.planId, table.createdAt), })); export const lessonPlanTemplates = mysqlTable("lesson_plan_templates", { id: id("id").primaryKey(), name: varchar("name", { length: 100 }).notNull(), type: varchar("type", { length: 50 }).notNull(), // system | personal scope: varchar("scope", { length: 50 }).notNull(), // regular | review | experiment | inquiry | blank | custom blocks: json("blocks").notNull(), creatorId: varchar("creator_id", { length: 128 }).references(() => users.id), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), }, (table) => ({ typeCreatorIdx: index("lpt_type_creator_idx").on(table.type, table.creatorId), })); ``` - [ ] **Step 2: 生成迁移并应用** Run: `npm run db:generate && npm run db:migrate` Expected: 生成新迁移文件,迁移成功 - [ ] **Step 3: 验证类型** Run: `npx tsc --noEmit` Expected: 无错误 - [ ] **Step 4: Commit** ```bash git add src/shared/db/schema.ts drizzle/ git commit -m "feat(lesson-preparation): add lesson_plans, lesson_plan_versions, lesson_plan_templates tables" ``` --- ## Task 2:权限点 + 角色映射 **Files:** - Modify: `src/shared/types/permissions.ts` - Modify: `src/shared/lib/permissions.ts` - [ ] **Step 1: 在 permissions.ts 的 `Permissions` 对象末尾(DIAGNOSTIC_READ 之后)新增** ```typescript // Lesson Plan (备课) LESSON_PLAN_CREATE: "lesson_plan:create", LESSON_PLAN_READ: "lesson_plan:read", LESSON_PLAN_UPDATE: "lesson_plan:update", LESSON_PLAN_DELETE: "lesson_plan:delete", LESSON_PLAN_PUBLISH: "lesson_plan:publish", ``` - [ ] **Step 2: 在 permissions.ts 的 ROLE_PERMISSIONS 中,admin 和 teacher 数组都追加这 5 个权限** admin 数组末尾追加: ```typescript Permissions.LESSON_PLAN_CREATE, Permissions.LESSON_PLAN_READ, Permissions.LESSON_PLAN_UPDATE, Permissions.LESSON_PLAN_DELETE, Permissions.LESSON_PLAN_PUBLISH, ``` teacher 数组末尾追加同样 5 个。 - [ ] **Step 3: 验证** Run: `npx tsc --noEmit && npm run lint` Expected: 无错误 - [ ] **Step 4: Commit** ```bash git add src/shared/types/permissions.ts src/shared/lib/permissions.ts git commit -m "feat(lesson-preparation): add 5 lesson_plan permission points" ``` --- ## Task 3:类型定义 + 常量 + 预置模板 **Files:** - Create: `src/modules/lesson-preparation/types.ts` - Create: `src/modules/lesson-preparation/constants.ts` - [ ] **Step 1: 创建 types.ts** ```typescript // 课案状态 export type LessonPlanStatus = "draft" | "published" | "archived"; // Block 类型枚举 export type BlockType = | "objective" | "key_point" | "import" | "new_teaching" | "consolidation" | "summary" | "homework" | "blackboard" | "text_study" | "exercise" | "rich_text" | "reflection"; // 富文本类 block 的 data export interface RichTextBlockData { html: string; knowledgePointIds: string[]; } // 文本研习 block 的 data export interface TextStudyAnnotation { id: string; anchor: { start: number; end: number }; nodeType: string; title: string; note: string; color: "yellow" | "green"; } export interface TextStudyBlockData { sourceText: string; annotations: TextStudyAnnotation[]; knowledgePointIds: string[]; } // 练习 block 的 data export type ExercisePurpose = "class_practice" | "after_class_homework"; export interface InlineQuestionContent { content: unknown; // 与 questions.content 对齐 type: string; // 与 questionTypeEnum 对齐 difficulty: number; knowledgePointIds: string[]; } export interface ExerciseItem { questionId: string; // bank=真实ID;inline=占位 inline_draft_xxx source: "bank" | "inline"; score: number; order: number; inlineContent?: InlineQuestionContent; // 仅 inline } export interface ExerciseBlockData { items: ExerciseItem[]; purpose: ExercisePurpose; knowledgePointIds: string[]; publishedAssignmentId?: string; publishedExamId?: string; publishedAt?: string; } // Block 联合 export interface Block { id: string; type: BlockType; title: string; data: RichTextBlockData | TextStudyBlockData | ExerciseBlockData; order: number; } // 文档 export interface LessonPlanDocument { version: 1; blocks: Block[]; } // 课案 export interface LessonPlan { id: string; title: string; textbookId: string | null; chapterId: string | null; coursePlanItemId: string | null; subjectId: string | null; gradeId: string | null; templateId: string | null; templateName: string | null; content: LessonPlanDocument; status: LessonPlanStatus; creatorId: string; lastSavedAt: string | null; createdAt: string; updatedAt: string; } // 版本 export interface LessonPlanVersion { id: string; planId: string; versionNo: number; label: string | null; content: LessonPlanDocument; isAuto: boolean; creatorId: string; createdAt: string; } // 模板 export type TemplateType = "system" | "personal"; export type TemplateScope = "regular" | "review" | "experiment" | "inquiry" | "blank" | "custom"; export interface TemplateBlockSkeleton { type: BlockType; title: string; hint?: string; } export interface LessonPlanTemplate { id: string; name: string; type: TemplateType; scope: TemplateScope; blocks: TemplateBlockSkeleton[]; creatorId: string | null; createdAt: string; updatedAt: string; } // 列表项(带教材/章节名) export interface LessonPlanListItem extends LessonPlan { textbookTitle: string | null; chapterTitle: string | null; subjectName: string | null; gradeName: string | null; creatorName: string | null; } // ActionState(与项目现有约定一致) export type ActionState = { success: boolean; message?: string; errors?: Record; data?: T; }; ``` - [ ] **Step 2: 创建 constants.ts(含 4+1 套预置模板定义)** ```typescript import type { BlockType, TemplateBlockSkeleton, TemplateScope } from "./types"; // block 类型 → 中文默认标题 export const BLOCK_TYPE_LABELS: Record = { objective: "教学目标", key_point: "教学重难点", import: "导入", new_teaching: "新授", consolidation: "巩固练习", summary: "课堂小结", homework: "作业布置", blackboard: "板书设计", text_study: "文本研习", exercise: "练习/作业", rich_text: "自定义环节", reflection: "教学反思", }; // 富文本类 block(共享同一编辑组件) export const RICH_TEXT_BLOCK_TYPES: BlockType[] = [ "objective", "key_point", "import", "new_teaching", "consolidation", "summary", "homework", "blackboard", "rich_text", "reflection", ]; // 系统预设模板骨架(seed 用) export interface SystemTemplateDef { id: string; // 固定 ID,便于 seed 幂等 name: string; scope: TemplateScope; blocks: TemplateBlockSkeleton[]; } export const SYSTEM_TEMPLATES: SystemTemplateDef[] = [ { id: "tpl_regular", name: "常规课", scope: "regular", blocks: [ { type: "objective", title: "教学目标", hint: "明确本课的知识、能力、情感目标" }, { type: "key_point", title: "教学重难点", hint: "标注重点与难点及突破策略" }, { type: "import", title: "导入", hint: "情境导入/复习导入/问题导入" }, { type: "new_teaching", title: "新授", hint: "核心教学活动设计" }, { type: "consolidation", title: "巩固练习", hint: "课堂练习,检验学习效果" }, { type: "summary", title: "课堂小结", hint: "归纳本课要点" }, { type: "homework", title: "作业布置", hint: "课后作业说明(如需下发请用练习块)" }, { type: "blackboard", title: "板书设计", hint: "板书结构示意" }, ], }, { id: "tpl_review", name: "复习课", scope: "review", blocks: [ { type: "objective", title: "复习目标" }, { type: "rich_text", title: "知识网络梳理", hint: "构建知识结构图" }, { type: "rich_text", title: "典型例题精讲" }, { type: "rich_text", title: "变式训练" }, { type: "exercise", title: "当堂检测", hint: "purpose 选 class_practice" }, { type: "summary", title: "课堂小结" }, ], }, { id: "tpl_experiment", name: "实验课", scope: "experiment", blocks: [ { type: "objective", title: "实验目的" }, { type: "rich_text", title: "器材准备" }, { type: "rich_text", title: "实验步骤" }, { type: "rich_text", title: "观察记录表" }, { type: "rich_text", title: "交流讨论" }, { type: "summary", title: "课堂小结" }, ], }, { id: "tpl_inquiry", name: "探究课", scope: "inquiry", blocks: [ { type: "rich_text", title: "情境导入" }, { type: "rich_text", title: "问题驱动" }, { type: "rich_text", title: "小组探究" }, { type: "rich_text", title: "成果展示" }, { type: "rich_text", title: "归纳提升" }, ], }, { id: "tpl_blank", name: "空白模板", scope: "blank", blocks: [], }, ]; export const LESSON_PLAN_STATUS_LABELS: Record = { draft: "草稿", published: "已发布", archived: "已归档", }; ``` - [ ] **Step 3: 验证** Run: `npx tsc --noEmit && npm run lint` Expected: 无错误 - [ ] **Step 4: Commit** ```bash git add src/modules/lesson-preparation/types.ts src/modules/lesson-preparation/constants.ts git commit -m "feat(lesson-preparation): add types and system template definitions" ``` --- ## Task 4:Zod 校验 schema **Files:** - Create: `src/modules/lesson-preparation/schema.ts` - [ ] **Step 1: 创建 schema.ts** ```typescript import { z } from "zod"; export const createLessonPlanSchema = z.object({ title: z.string().min(1, "请输入课案标题").max(255), textbookId: z.string().optional(), chapterId: z.string().optional(), subjectId: z.string().optional(), gradeId: z.string().optional(), templateId: z.string().min(1, "请选择模板"), }); export const updateLessonPlanContentSchema = z.object({ planId: z.string().min(1), title: z.string().min(1).max(255).optional(), content: z.unknown(), // Block 文档结构由 types 守卫,运行时只校验存在 }); export const saveVersionSchema = z.object({ planId: z.string().min(1), label: z.string().max(100).optional(), }); export const revertVersionSchema = z.object({ planId: z.string().min(1), versionNo: z.number().int().positive(), }); export const saveAsTemplateSchema = z.object({ sourcePlanId: z.string().min(1), name: z.string().min(1).max(100), }); export type CreateLessonPlanInput = z.infer; export type UpdateLessonPlanContentInput = z.infer; ``` - [ ] **Step 2: 验证 + Commit** Run: `npx tsc --noEmit` Commit: `feat(lesson-preparation): add zod schemas` --- ## Task 5:data-access 层(课案 CRUD) **Files:** - Create: `src/modules/lesson-preparation/data-access.ts` - [ ] **Step 1: 创建 data-access.ts** ```typescript import "server-only"; import { cache } from "react"; import { and, asc, desc, eq, inArray, like, or, type SQL } from "drizzle-orm"; import { createId } from "@paralleldrive/cuid2"; import { db } from "@/shared/db"; import { lessonPlans, lessonPlanVersions, lessonPlanTemplates, textbooks, chapters, subjects, grades, users, } from "@/shared/db/schema"; import type { DataScope } from "@/shared/types/permissions"; import { SYSTEM_TEMPLATES } from "./constants"; import type { LessonPlan, LessonPlanListItem, LessonPlanDocument, LessonPlanTemplate, TemplateBlockSkeleton, } from "./types"; // ---- 模板初始化:根据 templateId 生成初始 content ---- export function buildInitialContent(blocks: TemplateBlockSkeleton[]): LessonPlanDocument { return { version: 1, blocks: blocks.map((b, i) => ({ id: createId(), type: b.type, title: b.title, data: b.type === "exercise" ? { items: [], purpose: "class_practice", knowledgePointIds: [] } : b.type === "text_study" ? { sourceText: "", annotations: [], knowledgePointIds: [] } : { html: "", knowledgePointIds: [] }, order: i, })), }; } // ---- DataScope → 查询条件 ---- function buildScopeCondition(scope: DataScope, userId: string): SQL[] { switch (scope.type) { case "all": return []; case "owned": return [eq(lessonPlans.creatorId, userId)]; case "class_taught": case "grade_managed": case "class_members": case "children": // 教师看自己创建的 + published 的 return [or(eq(lessonPlans.creatorId, userId), eq(lessonPlans.status, "published"))!]; } } // ---- 课案列表 ---- export const getLessonPlans = cache(async ( params: { query?: string; textbookId?: string; chapterId?: string; subjectId?: string; status?: string; }, scope: DataScope, userId: string, ): Promise => { const conditions: SQL[] = [ne(lessonPlans.status, "archived")]; conditions.push(...buildScopeCondition(scope, userId)); if (params.query) { conditions.push(like(lessonPlans.title, `%${params.query}%`)); } if (params.textbookId) conditions.push(eq(lessonPlans.textbookId, params.textbookId)); if (params.chapterId) conditions.push(eq(lessonPlans.chapterId, params.chapterId)); if (params.subjectId) conditions.push(eq(lessonPlans.subjectId, params.subjectId)); if (params.status) conditions.push(eq(lessonPlans.status, params.status)); const rows = await db .select({ id: lessonPlans.id, title: lessonPlans.title, textbookId: lessonPlans.textbookId, chapterId: lessonPlans.chapterId, coursePlanItemId: lessonPlans.coursePlanItemId, subjectId: lessonPlans.subjectId, gradeId: lessonPlans.gradeId, templateId: lessonPlans.templateId, templateName: lessonPlans.templateName, content: lessonPlans.content, status: lessonPlans.status, creatorId: lessonPlans.creatorId, lastSavedAt: lessonPlans.lastSavedAt, createdAt: lessonPlans.createdAt, updatedAt: lessonPlans.updatedAt, textbookTitle: textbooks.title, chapterTitle: chapters.title, subjectName: subjects.name, gradeName: grades.name, creatorName: users.name, }) .from(lessonPlans) .leftJoin(textbooks, eq(lessonPlans.textbookId, textbooks.id)) .leftJoin(chapters, eq(lessonPlans.chapterId, chapters.id)) .leftJoin(subjects, eq(lessonPlans.subjectId, subjects.id)) .leftJoin(grades, eq(lessonPlans.gradeId, grades.id)) .leftJoin(users, eq(lessonPlans.creatorId, users.id)) .where(and(...conditions)) .orderBy(desc(lessonPlans.updatedAt)); return rows as unknown as LessonPlanListItem[]; }); // ---- 单课案 ---- export const getLessonPlanById = cache(async ( id: string, userId: string, ): Promise => { const rows = await db.select().from(lessonPlans).where(eq(lessonPlans.id, id)).limit(1); if (rows.length === 0) return null; const row = rows[0]; // 权限:creator 可看 draft;非 creator 仅 published if (row.creatorId !== userId && row.status !== "published") return null; return row as unknown as LessonPlan; }); // ---- 创建 ---- export async function createLessonPlan(input: { title: string; textbookId?: string; chapterId?: string; subjectId?: string; gradeId?: string; templateId: string; creatorId: string; }): Promise<{ planId: string }> { const template = await getTemplateById(input.templateId); if (!template) throw new Error("模板不存在"); const planId = createId(); const content = buildInitialContent(template.blocks); await db.insert(lessonPlans).values({ id: planId, title: input.title, textbookId: input.textbookId ?? null, chapterId: input.chapterId ?? null, subjectId: input.subjectId ?? null, gradeId: input.gradeId ?? null, templateId: template.id, templateName: template.name, content, status: "draft", creatorId: input.creatorId, lastSavedAt: new Date(), }); return { planId }; } // ---- 更新 content(自动保存,不生成版本)---- export async function updateLessonPlanContent( planId: string, userId: string, patch: { title?: string; content: LessonPlanDocument }, ): Promise { await db.update(lessonPlans) .set({ ...(patch.title ? { title: patch.title } : {}), content: patch.content, lastSavedAt: new Date(), }) .where(and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId))); } // ---- 软删除 ---- export async function softDeleteLessonPlan(planId: string, userId: string): Promise { await db.update(lessonPlans) .set({ status: "archived" }) .where(and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId))); } // ---- 复制 ---- export async function duplicateLessonPlan(planId: string, userId: string): Promise<{ newPlanId: string }> { const src = await getLessonPlanById(planId, userId); if (!src) throw new Error("课案不存在或无权访问"); const newId = createId(); await db.insert(lessonPlans).values({ id: newId, title: `${src.title} - 副本`, textbookId: src.textbookId, chapterId: src.chapterId, subjectId: src.subjectId, gradeId: src.gradeId, templateId: src.templateId, templateName: src.templateName, content: src.content, status: "draft", creatorId: userId, lastSavedAt: new Date(), }); return { newPlanId: newId }; } // ---- 模板查询(内部)---- async function getTemplateById(templateId: string): Promise { // 先查 system 固定模板 const sysDef = SYSTEM_TEMPLATES.find((t) => t.id === templateId); if (sysDef) { return { id: sysDef.id, name: sysDef.name, type: "system", scope: sysDef.scope, blocks: sysDef.blocks, creatorId: null, createdAt: "", updatedAt: "", }; } // 再查 DB(personal 模板) const rows = await db.select().from(lessonPlanTemplates).where(eq(lessonPlanTemplates.id, templateId)).limit(1); return rows.length > 0 ? rows[0] as unknown as LessonPlanTemplate : null; } ``` > 注意:`ne` 需从 drizzle-orm 导入;若项目无 `ne`,用 `sql\`${lessonPlans.status} != 'archived'\`` 替代。 - [ ] **Step 2: 验证 + Commit** Run: `npx tsc --noEmit && npm run lint` Commit: `feat(lesson-preparation): add lesson plan data-access (CRUD)` --- ## Task 6:data-access-versions + data-access-templates **Files:** - Create: `src/modules/lesson-preparation/data-access-versions.ts` - Create: `src/modules/lesson-preparation/data-access-templates.ts` - [ ] **Step 1: 创建 data-access-versions.ts** ```typescript import "server-only"; import { and, desc, eq, max } from "drizzle-orm"; import { createId } from "@paralleldrive/cuid2"; import { db } from "@/shared/db"; import { lessonPlanVersions, lessonPlans } from "@/shared/db/schema"; import type { LessonPlanDocument, LessonPlanVersion } from "./types"; export async function getLessonPlanVersions(planId: string, userId: string): Promise { // 校验归属 const plan = await db.select({ id: lessonPlans.id }).from(lessonPlans) .where(and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId))).limit(1); if (plan.length === 0) return []; const rows = await db.select().from(lessonPlanVersions) .where(eq(lessonPlanVersions.planId, planId)) .orderBy(desc(lessonPlanVersions.versionNo)); return rows as unknown as LessonPlanVersion[]; } export async function createLessonPlanVersion(input: { planId: string; content: LessonPlanDocument; userId: string; isAuto: boolean; label?: string; }): Promise<{ versionNo: number }> { // 取当前最大 versionNo const maxRow = await db.select({ maxNo: max(lessonPlanVersions.versionNo) }) .from(lessonPlanVersions).where(eq(lessonPlanVersions.planId, input.planId)); const nextNo = (maxRow[0]?.maxNo ?? 0) + 1; await db.insert(lessonPlanVersions).values({ id: createId(), planId: input.planId, versionNo: nextNo, label: input.label ?? null, content: input.content, isAuto: input.isAuto, creatorId: input.userId, }); return { versionNo: nextNo }; } export async function getVersionContent(planId: string, versionNo: number, userId: string): Promise { const rows = await db.select({ content: lessonPlanVersions.content }) .from(lessonPlanVersions) .where(and( eq(lessonPlanVersions.planId, planId), eq(lessonPlanVersions.versionNo, versionNo), )).limit(1); if (rows.length === 0) return null; return rows[0].content as LessonPlanDocument; } export async function revertToVersion(planId: string, versionNo: number, userId: string): Promise<{ newVersionNo: number } | null> { const content = await getVersionContent(planId, versionNo, userId); if (!content) return null; // 用该版本 content 覆盖当前 + 生成新版本 await db.update(lessonPlans).set({ content, lastSavedAt: new Date() }) .where(and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId))); const { versionNo: newNo } = await createLessonPlanVersion({ planId, content, userId, isAuto: false, label: `回退到 v${versionNo}`, }); return { newVersionNo: newNo }; } export async function pruneAutoVersions(planId: string, keep = 50): Promise { const rows = await db.select({ id: lessonPlanVersions.id, isAuto: lessonPlanVersions.isAuto, versionNo: lessonPlanVersions.versionNo }) .from(lessonPlanVersions) .where(eq(lessonPlanVersions.planId, planId)) .orderBy(desc(lessonPlanVersions.versionNo)); if (rows.length <= keep) return; // 保留前 keep 条;超出部分只删 isAuto=true 的 const toDelete = rows.slice(keep).filter((r) => r.isAuto); if (toDelete.length === 0) return; await db.delete(lessonPlanVersions) .where(and( eq(lessonPlanVersions.planId, planId), inArray(lessonPlanVersions.id, toDelete.map((r) => r.id)), )); } import { inArray } from "drizzle-orm"; ``` > 注意:`inArray` 导入应放在文件顶部,此处为示意;实际编写时合并到顶部 import。 - [ ] **Step 2: 创建 data-access-templates.ts** ```typescript import "server-only"; import { and, eq } from "drizzle-orm"; import { createId } from "@paralleldrive/cuid2"; import { db } from "@/shared/db"; import { lessonPlanTemplates, lessonPlans } from "@/shared/db/schema"; import { SYSTEM_TEMPLATES } from "./constants"; import type { LessonPlanTemplate, TemplateBlockSkeleton } from "./types"; export async function getLessonPlanTemplates(userId: string): Promise { // system 模板(内存)+ personal 模板(DB) const systemTemplates: LessonPlanTemplate[] = SYSTEM_TEMPLATES.map((t) => ({ id: t.id, name: t.name, type: "system", scope: t.scope, blocks: t.blocks, creatorId: null, createdAt: "", updatedAt: "", })); const personalRows = await db.select().from(lessonPlanTemplates) .where(and(eq(lessonPlanTemplates.type, "personal"), eq(lessonPlanTemplates.creatorId, userId))); const personalTemplates = personalRows as unknown as LessonPlanTemplate[]; return [...systemTemplates, ...personalTemplates]; } export async function saveAsTemplate(input: { sourcePlanId: string; name: string; userId: string; }): Promise<{ templateId: string }> { // 从课案 content 提取 block 骨架 const plan = await db.select({ content: lessonPlans.content }) .from(lessonPlans) .where(and(eq(lessonPlans.id, input.sourcePlanId), eq(lessonPlans.creatorId, input.userId))) .limit(1); if (plan.length === 0) throw new Error("课案不存在或无权访问"); const doc = plan[0].content as { blocks: Array<{ type: string; title: string }> }; const skeleton: TemplateBlockSkeleton[] = doc.blocks.map((b) => ({ type: b.type as never, title: b.title, })); const templateId = createId(); await db.insert(lessonPlanTemplates).values({ id: templateId, name: input.name, type: "personal", scope: "custom", blocks: skeleton, creatorId: input.userId, }); return { templateId }; } export async function deletePersonalTemplate(templateId: string, userId: string): Promise { await db.delete(lessonPlanTemplates) .where(and( eq(lessonPlanTemplates.id, templateId), eq(lessonPlanTemplates.type, "personal"), eq(lessonPlanTemplates.creatorId, userId), )); } ``` - [ ] **Step 3: 验证 + Commit** Run: `npx tsc --noEmit && npm run lint` Commit: `feat(lesson-preparation): add versions and templates data-access` --- ## Task 7:Server Actions(P0 基础) **Files:** - Create: `src/modules/lesson-preparation/actions.ts` - [ ] **Step 1: 创建 actions.ts** ```typescript "use server"; import { revalidatePath } from "next/cache"; import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"; import { Permissions } from "@/shared/types/permissions"; import { getLessonPlans, getLessonPlanById, createLessonPlan, updateLessonPlanContent, softDeleteLessonPlan, duplicateLessonPlan, } from "./data-access"; import { getLessonPlanVersions, createLessonPlanVersion, revertToVersion, pruneAutoVersions, } from "./data-access-versions"; import { getLessonPlanTemplates, saveAsTemplate, deletePersonalTemplate, } from "./data-access-templates"; import { createLessonPlanSchema, updateLessonPlanContentSchema, saveVersionSchema, revertVersionSchema, saveAsTemplateSchema, } from "./schema"; import type { ActionState, LessonPlanDocument } from "./types"; async function getCtx() { return await requirePermission(Permissions.LESSON_PLAN_READ); } // ---- 课案列表 ---- export async function getLessonPlansAction(params: { query?: string; textbookId?: string; chapterId?: string; subjectId?: string; status?: string; }): Promise> }>> { try { const ctx = await getCtx(); 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: "获取课案列表失败" }; } } // ---- 单课案 ---- export async function getLessonPlanByIdAction(planId: string): Promise> }>> { try { const ctx = await getCtx(); const plan = await getLessonPlanById(planId, ctx.userId); 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: "获取课案失败" }; } } // ---- 创建 ---- export async function createLessonPlanAction( prevState: ActionState | null, formData: FormData, ): Promise> { try { const ctx = await requirePermission(Permissions.LESSON_PLAN_CREATE); const parsed = createLessonPlanSchema.safeParse({ title: formData.get("title"), textbookId: formData.get("textbookId") || undefined, chapterId: formData.get("chapterId") || undefined, subjectId: formData.get("subjectId") || undefined, gradeId: formData.get("gradeId") || undefined, templateId: formData.get("templateId"), }); if (!parsed.success) { return { success: false, errors: parsed.error.flatten().fieldErrors }; } const { planId } = await createLessonPlan({ ...parsed.data, creatorId: ctx.userId }); revalidatePath("/teacher/lesson-plans"); return { success: true, data: { planId } }; } catch (e) { if (e instanceof PermissionDeniedError) return { success: false, message: e.message }; return { success: false, message: "创建课案失败" }; } } // ---- 更新 content(自动保存)---- export async function updateLessonPlanAction(input: { planId: string; title?: string; content: LessonPlanDocument; }): Promise { try { const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE); const parsed = updateLessonPlanContentSchema.safeParse(input); if (!parsed.success) 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, }); return { success: true }; } catch (e) { if (e instanceof PermissionDeniedError) return { success: false, message: e.message }; return { success: false, message: "保存失败" }; } } // ---- 手动保存版本 ---- export async function saveLessonPlanVersionAction(input: { planId: string; content: LessonPlanDocument; label?: string; }): Promise> { try { const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE); const parsed = saveVersionSchema.safeParse(input); if (!parsed.success) return { success: false, errors: parsed.error.flatten().fieldErrors }; const { versionNo } = await createLessonPlanVersion({ planId: parsed.data.planId, content: input.content, userId: ctx.userId, isAuto: false, label: parsed.data.label, }); 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: "保存版本失败" }; } } // ---- 版本列表 ---- export async function getLessonPlanVersionsAction(planId: string): Promise> }>> { try { const ctx = await getCtx(); 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: "获取版本失败" }; } } // ---- 回退版本 ---- export async function revertLessonPlanVersionAction(input: { planId: string; versionNo: number; }): Promise> { try { const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE); const parsed = revertVersionSchema.safeParse(input); if (!parsed.success) return { success: false, errors: parsed.error.flatten().fieldErrors }; const result = await revertToVersion(parsed.data.planId, parsed.data.versionNo, ctx.userId); 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: "回退失败" }; } } // ---- 删除(软删除)---- export async function deleteLessonPlanAction(planId: string): Promise { 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: "删除失败" }; } } // ---- 复制 ---- export async function duplicateLessonPlanAction(planId: string): Promise> { try { const ctx = await requirePermission(Permissions.LESSON_PLAN_CREATE); const { newPlanId } = await duplicateLessonPlan(planId, ctx.userId); revalidatePath("/teacher/lesson-plans"); return { success: true, data: { newPlanId } }; } catch (e) { if (e instanceof PermissionDeniedError) return { success: false, message: e.message }; return { success: false, message: "复制失败" }; } } // ---- 模板列表 ---- export async function getLessonPlanTemplatesAction(): Promise> }>> { try { const ctx = await getCtx(); 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: "获取模板失败" }; } } // ---- 另存为模板 ---- export async function saveAsTemplateAction(input: { sourcePlanId: string; name: string; }): Promise> { try { const ctx = await requirePermission(Permissions.LESSON_PLAN_CREATE); const parsed = saveAsTemplateSchema.safeParse(input); if (!parsed.success) return { success: false, errors: parsed.error.flatten().fieldErrors }; const { templateId } = await saveAsTemplate({ ...parsed.data, userId: ctx.userId }); return { success: true, data: { templateId } }; } catch (e) { if (e instanceof PermissionDeniedError) return { success: false, message: e.message }; return { success: false, message: "保存模板失败" }; } } // ---- 删除模板 ---- export async function deleteTemplateAction(templateId: string): Promise { try { const ctx = await requirePermission(Permissions.LESSON_PLAN_DELETE); await deletePersonalTemplate(templateId, ctx.userId); return { success: true }; } catch (e) { if (e instanceof PermissionDeniedError) return { success: false, message: e.message }; return { success: false, message: "删除模板失败" }; } } ``` - [ ] **Step 2: 验证 + Commit** Run: `npx tsc --noEmit && npm run lint` Commit: `feat(lesson-preparation): add P0 server actions` --- ## Task 8:编辑器状态 Hook(zustand) **Files:** - Create: `src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts` - [ ] **Step 1: 创建 hook** ```typescript "use client"; import { create } from "zustand"; import { createId } from "@paralleldrive/cuid2"; import type { Block, BlockType, LessonPlanDocument } from "../types"; import { BLOCK_TYPE_LABELS } from "../constants"; interface EditorState { planId: string; title: string; doc: LessonPlanDocument; isDirty: boolean; isSaving: boolean; lastSavedAt: number | null; setTitle: (title: string) => void; addBlock: (type: BlockType, index?: number) => void; updateBlock: (id: string, patch: Partial) => void; removeBlock: (id: string) => void; moveBlock: (id: string, toIndex: number) => void; reorderBlocks: (blocks: Block[]) => void; markSaved: () => void; setSaving: (saving: boolean) => void; replaceDoc: (doc: LessonPlanDocument) => void; } export const useLessonPlanEditor = create((set) => ({ planId: "", title: "", doc: { version: 1, blocks: [] }, isDirty: false, isSaving: false, lastSavedAt: null, setTitle: (title) => set({ title, isDirty: true }), addBlock: (type, index) => set((s) => { const newBlock: Block = { id: createId(), type, title: BLOCK_TYPE_LABELS[type], data: type === "exercise" ? { items: [], purpose: "class_practice", knowledgePointIds: [] } : type === "text_study" ? { sourceText: "", annotations: [], knowledgePointIds: [] } : { html: "", knowledgePointIds: [] }, order: 0, }; const blocks = [...s.doc.blocks]; const at = index ?? blocks.length; blocks.splice(at, 0, newBlock); return { doc: { version: 1, blocks: reindex(blocks) }, isDirty: true }; }), updateBlock: (id, patch) => set((s) => ({ doc: { version: 1, blocks: s.doc.blocks.map((b) => (b.id === id ? { ...b, ...patch } : b)), }, isDirty: true, })), removeBlock: (id) => set((s) => ({ doc: { version: 1, blocks: reindex(s.doc.blocks.filter((b) => b.id !== id)) }, isDirty: true, })), moveBlock: (id, toIndex) => set((s) => { const blocks = [...s.doc.blocks]; const from = blocks.findIndex((b) => b.id === id); if (from === -1) return s; const [moved] = blocks.splice(from, 1); blocks.splice(toIndex, 0, moved); return { doc: { version: 1, blocks: reindex(blocks) }, isDirty: true }; }), reorderBlocks: (blocks) => set({ doc: { version: 1, blocks: reindex(blocks) }, isDirty: true }), markSaved: () => set({ isDirty: false, lastSavedAt: Date.now() }), setSaving: (saving) => set({ isSaving: saving }), replaceDoc: (doc) => set({ doc, isDirty: false }), })); function reindex(blocks: Block[]): Block[] { return blocks.map((b, i) => ({ ...b, order: i })); } ``` - [ ] **Step 2: 验证 + Commit** Run: `npx tsc --noEmit && npm run lint` Commit: `feat(lesson-preparation): add editor state hook` --- ## Task 9:Block 组件(富文本类 + 渲染分发) **Files:** - Create: `src/modules/lesson-preparation/components/blocks/rich-text-block.tsx` - Create: `src/modules/lesson-preparation/components/block-renderer.tsx` - [ ] **Step 1: 创建 rich-text-block.tsx(Tiptap 编辑器)** ```tsx "use client"; import { useEditor, EditorContent } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; import { useEffect } from "react"; import type { RichTextBlockData } from "../../types"; interface Props { blockId: string; data: RichTextBlockData; hint?: string; onUpdate: (data: RichTextBlockData) => void; } export function RichTextBlock({ blockId, data, hint, onUpdate }: Props) { const editor = useEditor({ extensions: [ StarterKit, Placeholder.configure({ placeholder: hint ?? "输入内容..." }), ], content: data.html, onUpdate: ({ editor }) => { onUpdate({ ...data, html: editor.getHTML() }); }, editorProps: { attributes: { class: "prose prose-sm max-w-none focus:outline-none min-h-[60px] px-3 py-2" }, }, }); // 外部 content 变化时同步(如版本回退) useEffect(() => { if (editor && !editor.isDestroyed && data.html !== editor.getHTML()) { editor.commands.setContent(data.html, false); } }, [data.html, editor]); return ; } ``` - [ ] **Step 2: 创建 block-renderer.tsx(分发 + 拖拽容器)** ```tsx "use client"; import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { GripVertical, Trash2, ChevronUp, ChevronDown } from "lucide-react"; import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor"; import { RICH_TEXT_BLOCK_TYPES } from "../constants"; import { RichTextBlock } from "./blocks/rich-text-block"; import type { Block } from "../types"; function SortableBlock({ block, index, total }: { block: Block; index: number; total: number }) { const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: block.id }); const { updateBlock, removeBlock, moveBlock } = useLessonPlanEditor(); const style = { transform: CSS.Transform.toString(transform), transition }; const isRichText = RICH_TEXT_BLOCK_TYPES.includes(block.type); return (
updateBlock(block.id, { title: e.target.value })} className="flex-1 bg-transparent font-title-md text-title-md focus:outline-none" />
{isRichText ? ( updateBlock(block.id, { data: d })} /> ) : block.type === "exercise" ? (
练习块(P1 实现)
) : block.type === "text_study" ? (
文本研习块(P1 实现)
) : (
未知 block 类型
)}
); } export function BlockRenderer() { const { doc, reorderBlocks } = useLessonPlanEditor(); function onDragEnd(e: DragEndEvent) { const { active, over } = e; if (!over || active.id === over.id) return; const oldIndex = doc.blocks.findIndex((b) => b.id === active.id); const newIndex = doc.blocks.findIndex((b) => b.id === over.id); if (oldIndex === -1 || newIndex === -1) return; const blocks = [...doc.blocks]; const [moved] = blocks.splice(oldIndex, 1); blocks.splice(newIndex, 0, moved); reorderBlocks(blocks); } return ( b.id)} strategy={verticalListSortingStrategy}>
{doc.blocks.map((b, i) => ( ))}
); } ``` - [ ] **Step 3: 验证 + Commit** Run: `npx tsc --noEmit && npm run lint` Commit: `feat(lesson-preparation): add rich-text block and block renderer` --- ## Task 10:编辑器主壳 + 自动保存 + 版本抽屉 + 模板选择器 **Files:** - Create: `src/modules/lesson-preparation/components/lesson-plan-editor.tsx` - Create: `src/modules/lesson-preparation/components/version-history-drawer.tsx` - Create: `src/modules/lesson-preparation/components/template-picker.tsx` - Create: `src/modules/lesson-preparation/components/lesson-plan-list.tsx` - Create: `src/modules/lesson-preparation/components/lesson-plan-card.tsx` - Create: `src/modules/lesson-preparation/components/lesson-plan-filters.tsx` - [ ] **Step 1: 创建 lesson-plan-editor.tsx(主壳 + 自动保存)** ```tsx "use client"; import { useEffect, useRef, useState } from "react"; import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor"; import { BlockRenderer } from "./block-renderer"; import { VersionHistoryDrawer } from "./version-history-drawer"; import { updateLessonPlanAction, saveLessonPlanVersionAction, getLessonPlanVersionsAction, revertLessonPlanVersionAction, } from "../actions"; import { BLOCK_TYPE_LABELS } from "../constants"; import type { BlockType, LessonPlanDocument } from "../types"; import { Button } from "@/shared/components/ui/button"; import { Plus, Save, History } from "lucide-react"; interface Props { planId: string; initialTitle: string; initialDoc: LessonPlanDocument; } const BLOCK_TYPES_TO_ADD: BlockType[] = [ "objective", "key_point", "import", "new_teaching", "consolidation", "summary", "homework", "blackboard", "exercise", "text_study", "rich_text", "reflection", ]; export function LessonPlanEditor({ planId, initialTitle, initialDoc }: Props) { const editor = useLessonPlanEditor(); const [showVersions, setShowVersions] = useState(false); const [showAddMenu, setShowAddMenu] = useState(false); const autoSaveTimer = useRef | null>(null); const versionTimer = useRef | null>(null); // 初始化 useEffect(() => { useLessonPlanEditor.setState({ planId, title: initialTitle, doc: initialDoc, isDirty: false, lastSavedAt: Date.now(), }); }, [planId, initialTitle, initialDoc]); // 自动保存(debounce 3s) useEffect(() => { if (!editor.isDirty) return; if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current); autoSaveTimer.current = setTimeout(async () => { editor.setSaving(true); const res = await updateLessonPlanAction({ planId, title: editor.title, content: editor.doc, }); editor.setSaving(false); if (res.success) editor.markSaved(); }, 3000); return () => { if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current); }; }, [editor.isDirty, editor.title, editor.doc, planId]); // 定时自动版本(30min) useEffect(() => { versionTimer.current = setInterval(async () => { if (!editor.isDirty) return; await saveLessonPlanVersionAction({ planId, content: editor.doc, label: "自动版本", }); }, 30 * 60 * 1000); return () => { if (versionTimer.current) clearInterval(versionTimer.current); }; }, [planId]); async function handleManualSave() { editor.setSaving(true); const res = await saveLessonPlanVersionAction({ planId, content: editor.doc, }); editor.setSaving(false); if (res.success) editor.markSaved(); } return (
{/* 顶部工具栏 */}
editor.setTitle(e.target.value)} className="flex-1 bg-transparent font-headline-md text-headline-md focus:outline-none" /> {editor.isSaving ? "保存中..." : editor.isDirty ? "未保存" : "已保存"}
{/* Block 列表 */}
{showAddMenu && (
{BLOCK_TYPES_TO_ADD.map((t) => ( ))}
)}
setShowVersions(false)} planId={planId} onReverted={() => { /* 触发页面刷新由父组件处理 */ }} />
); } ``` - [ ] **Step 2: 创建 version-history-drawer.tsx** ```tsx "use client"; import { useEffect, useState } from "react"; import { getLessonPlanVersionsAction, revertLessonPlanVersionAction } from "../actions"; import { Button } from "@/shared/components/ui/button"; import type { LessonPlanVersion } from "../types"; interface Props { open: boolean; onClose: () => void; planId: string; onReverted: () => void; } export function VersionHistoryDrawer({ open, onClose, planId, onReverted }: Props) { const [versions, setVersions] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { if (!open) return; setLoading(true); getLessonPlanVersionsAction(planId).then((res) => { if (res.success && res.data) setVersions(res.data.versions); setLoading(false); }); }, [open, planId]); async function handleRevert(versionNo: number) { if (!confirm(`确认回退到 v${versionNo}?将生成新版本。`)) return; const res = await revertLessonPlanVersionAction({ planId, versionNo }); if (res.success) { onReverted(); onClose(); } else { alert(res.message); } } if (!open) return null; return (

版本历史

{loading ?

加载中...

: versions.length === 0 ?

暂无版本

: (
{versions.map((v) => (
v{v.versionNo} {v.isAuto && 自动}

{v.label ?? "手动保存"}

{new Date(v.createdAt).toLocaleString()}

))}
)}
); } ``` - [ ] **Step 3: 创建 template-picker.tsx** ```tsx "use client"; import { useState } from "react"; import { createLessonPlanAction } from "../actions"; import { useRouter } from "next/navigation"; import { Button } from "@/shared/components/ui/button"; import { SYSTEM_TEMPLATES } from "../constants"; export function TemplatePicker() { const router = useRouter(); const [selected, setSelected] = useState(""); const [title, setTitle] = useState(""); const [error, setError] = useState(null); async function handleSubmit(formData: FormData) { setError(null); formData.set("templateId", selected); formData.set("title", title); const res = await createLessonPlanAction(null, formData); if (res.success && res.data) { router.push(`/teacher/lesson-plans/${res.data.planId}/edit`); } else { setError(res.message ?? "创建失败"); } } return (
setTitle(e.target.value)} required className="w-full border border-outline-variant rounded-lg px-3 py-2" placeholder="例如:《秋天》第一课时" />
{SYSTEM_TEMPLATES.map((t) => ( ))}
{error &&

{error}

}
); } ``` - [ ] **Step 4: 创建 lesson-plan-card.tsx + lesson-plan-filters.tsx + lesson-plan-list.tsx** ```tsx // lesson-plan-card.tsx "use client"; import Link from "next/link"; import { Button } from "@/shared/components/ui/button"; import { LESSON_PLAN_STATUS_LABELS } from "../constants"; import { duplicateLessonPlanAction, deleteLessonPlanAction } from "../actions"; import type { LessonPlanListItem } from "../types"; export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) { return (

{plan.title}

{plan.textbookTitle ?? "无教材"} · {plan.chapterTitle ?? "无章节"}
{plan.templateName ?? "无模板"} · {LESSON_PLAN_STATUS_LABELS[plan.status]}
最后保存:{plan.lastSavedAt ? new Date(plan.lastSavedAt).toLocaleString() : "未保存"}
); } ``` ```tsx // lesson-plan-filters.tsx "use client"; import { useTransition } from "react"; interface Props { onFilter: (params: { query?: string; subjectId?: string; status?: string }) => void; subjects: { id: string; name: string }[]; } export function LessonPlanFilters({ onFilter, subjects }: Props) { const [isPending, startTransition] = useTransition(); return (
startTransition(() => onFilter({ query: e.target.value }))} className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm" />
); } ``` ```tsx // lesson-plan-list.tsx "use client"; import { useState } from "react"; import { LessonPlanCard } from "./lesson-plan-card"; import { LessonPlanFilters } from "./lesson-plan-filters"; import { getLessonPlansAction } from "../actions"; import type { LessonPlanListItem } from "../types"; interface Props { initialItems: LessonPlanListItem[]; subjects: { id: string; name: string }[]; } export function LessonPlanList({ initialItems, subjects }: Props) { const [items, setItems] = useState(initialItems); async function handleFilter(params: { query?: string; subjectId?: string; status?: string }) { const res = await getLessonPlansAction(params); if (res.success && res.data) setItems(res.data.items); } return (
{items.length === 0 ? (

暂无课案,点击"新建课案"开始

) : (
{items.map((p) => )}
)}
); } ``` - [ ] **Step 5: 验证 + Commit** Run: `npx tsc --noEmit && npm run lint` Commit: `feat(lesson-preparation): add editor shell, version drawer, template picker, list components` --- ## Task 11:路由页面 + 导航 **Files:** - Create: `src/app/(dashboard)/teacher/lesson-plans/page.tsx` - Create: `src/app/(dashboard)/teacher/lesson-plans/new/page.tsx` - Create: `src/app/(dashboard)/teacher/lesson-plans/[planId]/edit/page.tsx` - Modify: `src/modules/layout/config/navigation.ts` - [ ] **Step 1: 创建列表页 page.tsx** ```tsx import { getLessonPlansAction } from "@/modules/lesson-preparation/actions"; import { getSubjectOptions } from "@/modules/school/data-access"; import { LessonPlanList } from "@/modules/lesson-preparation/components/lesson-plan-list"; import { Button } from "@/shared/components/ui/button"; import Link from "next/link"; import { Plus } from "lucide-react"; export default async function LessonPlansPage() { const [plansRes, subjects] = await Promise.all([ getLessonPlansAction({}), getSubjectOptions(), ]); const items = plansRes.success && plansRes.data ? plansRes.data.items : []; return (

我的课案

); } ``` - [ ] **Step 2: 创建新建页 new/page.tsx** ```tsx import { TemplatePicker } from "@/modules/lesson-preparation/components/template-picker"; export default function NewLessonPlanPage() { return (

新建课案

); } ``` - [ ] **Step 3: 创建编辑页 [planId]/edit/page.tsx** ```tsx import { notFound } from "next/navigation"; import { getLessonPlanByIdAction } from "@/modules/lesson-preparation/actions"; import { LessonPlanEditor } from "@/modules/lesson-preparation/components/lesson-plan-editor"; export default async function EditLessonPlanPage({ params }: { params: Promise<{ planId: string }> }) { const { planId } = await params; const res = await getLessonPlanByIdAction(planId); if (!res.success || !res.data?.plan) notFound(); const { plan } = res.data; return (
); } ``` - [ ] **Step 4: 在 navigation.ts 的 teacher 菜单新增"备课"项** 在 teacher 导航数组中(适当位置,如"课程计划"附近)新增: ```typescript { title: "备课", href: "/teacher/lesson-plans", icon: "BookOpen", // 或项目使用的图标方案 permission: Permissions.LESSON_PLAN_READ, }, ``` > 需先读取 navigation.ts 确认现有结构与图标方案,再精确插入。 - [ ] **Step 5: 验证 + Commit** Run: `npx tsc --noEmit && npm run lint` Commit: `feat(lesson-preparation): add routes and navigation entry` --- ## Task 12:seed 系统预设模板 + 架构图同步 **Files:** - Create: `src/modules/lesson-preparation/seed-templates.ts` - Modify: `scripts/seed.ts` - [ ] **Step 1: 创建 seed-templates.ts** > 系统预设模板在 `constants.ts` 的 `SYSTEM_TEMPLATES` 中以固定 ID 定义,`data-access.ts` 的 `getTemplateById` 优先匹配内存定义。因此**无需写入 DB**,seed 脚本仅做幂等性校验(可选)。 ```typescript import "server-only"; import { SYSTEM_TEMPLATES } from "./constants"; // 系统模板以内存常量形式存在(固定 ID),无需 DB seed。 // 此函数供 scripts/seed.ts 调用以保持调用约定一致,当前为空操作。 export async function seedLessonPlanTemplates(): Promise { // 预留:若未来需要将 system 模板落库以便管理后台编辑,在此实现。 void SYSTEM_TEMPLATES; return; } ``` - [ ] **Step 2: 在 scripts/seed.ts 末尾调用(可选,保持约定)** 在 seed.ts 末尾其他 seed 调用之后追加: ```typescript import { seedLessonPlanTemplates } from "../src/modules/lesson-preparation/seed-templates"; // ... 在 main() 末尾 await seedLessonPlanTemplates(); console.log("✓ Lesson plan templates seeded"); ``` - [ ] **Step 3: 同步架构图 004** 在 `docs/architecture/004_architecture_impact_map.md`: - §1.1 分层架构图 modules 行新增 `lesson-preparation` - §1.2 新增 lesson-preparation 节点(依赖 textbooks/questions/exams/homework/classes/files,全部 ───▶ data-access 合理依赖) - 第二部分新增 §2.27 lesson-preparation 模块清单 - 附录 A 依赖矩阵新增一行一列 - [ ] **Step 4: 同步架构图 005** 在 `docs/architecture/005_architecture_data.json`: - `modules.lesson_preparation`:完整节点 - `dbTables`:新增 3 张表 - `permissions`:新增 5 个权限点 - `routes`:新增 3 个路由 - `dependencyMatrix`:新增依赖 - [ ] **Step 5: 验证 + Commit** Run: `npx tsc --noEmit && npm run lint` Commit: `feat(lesson-preparation): add seed stub and sync architecture docs` --- # 阶段 B:P1 联动 ## Task 13:知识点选择器 + 标注 **Files:** - Create: `src/modules/lesson-preparation/components/knowledge-point-picker.tsx` - Modify: `src/modules/lesson-preparation/components/blocks/rich-text-block.tsx`(加 KP 标注入口) - [ ] **Step 1: 创建 knowledge-point-picker.tsx(复用 textbooks 知识点树查询)** ```tsx "use client"; import { useEffect, useState } from "react"; import { getKnowledgePointsAction } from "@/modules/textbooks/actions"; import { Button } from "@/shared/components/ui/button"; import { Check, X } from "lucide-react"; interface Props { textbookId?: string; chapterId?: string; selectedIds: string[]; onChange: (ids: string[]) => void; onClose: () => void; } export function KnowledgePointPicker({ textbookId, chapterId, selectedIds, onChange, onClose }: Props) { const [kps, setKps] = useState<{ id: string; name: string; chapterTitle?: string }[]>([]); const [local, setLocal] = useState(selectedIds); useEffect(() => { if (!textbookId) return; getKnowledgePointsAction({ textbookId, chapterId }).then((res) => { if (res.success && res.data) setKps(res.data as never); }); }, [textbookId, chapterId]); function toggle(id: string) { setLocal((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]); } return (

选择知识点

{kps.length === 0 ?

未找到知识点,请先在教材模块创建

: (
{kps.map((kp) => ( ))}
)}
); } ``` > 注意:需先核对 `textbooks/actions.ts` 的 `getKnowledgePointsAction` 签名与返回结构,按实际调整。 - [ ] **Step 2: 在 rich-text-block.tsx 增加 KP 标注入口** 在 RichTextBlock 组件的编辑器下方追加知识点 chip 区 + "标注知识点"按钮,点击弹出 KnowledgePointPicker,确认后调用 `onUpdate({ ...data, knowledgePointIds: newIds })`。 - [ ] **Step 3: 验证 + Commit** Run: `npx tsc --noEmit && npm run lint` Commit: `feat(lesson-preparation): add knowledge point picker and annotation` --- ## Task 14:AI 知识点推荐 **Files:** - Create: `src/modules/lesson-preparation/ai-suggest.ts` - Create: `src/modules/lesson-preparation/actions-ai.ts` - [ ] **Step 1: 创建 ai-suggest.ts** ```typescript import "server-only"; import { createAiChatCompletion } from "@/shared/lib/ai"; import { getKnowledgePoints } from "@/modules/textbooks/data-access"; import type { LessonPlanDocument } from "./types"; export async function suggestKnowledgePoints( doc: LessonPlanDocument, textbookId?: string, chapterId?: string, ): Promise<{ id: string; name: string; reason: string }[]> { // 1. 提取课案纯文本 const text = doc.blocks .map((b) => { const d = b.data as { html?: string; sourceText?: string }; return d.html ?? d.sourceText ?? ""; }) .join("\n") .slice(0, 3000); if (!text.trim()) return []; // 2. 获取候选知识点池 if (!textbookId) return []; const allKps = await getKnowledgePoints({ textbookId, chapterId }); if (allKps.length === 0) return []; const kpList = allKps.map((kp) => ({ id: kp.id, name: kp.name })).slice(0, 100); // 3. 调用 AI const prompt = `你是教学设计助手。以下是教师备课内容: --- ${text} --- 请从下列知识点中推荐最相关的 3-8 个,并说明理由。返回 JSON 数组,每项含 id/name/reason。 候选知识点:${JSON.stringify(kpList)}`; const { content } = await createAiChatCompletion({ messages: [{ role: "user", content: prompt }], responseFormat: "json", }); try { const parsed = JSON.parse(content) as { id: string; name: string; reason: string }[]; // 过滤掉不在候选池中的 id const validIds = new Set(kpList.map((k) => k.id)); return parsed.filter((p) => validIds.has(p.id)); } catch { return []; } } ``` > 注意:需核对 `getKnowledgePoints` 的签名与 `createAiChatCompletion` 的参数格式(responseFormat 是否支持),按实际调整。 - [ ] **Step 2: 创建 actions-ai.ts** ```typescript "use server"; import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"; import { Permissions } from "@/shared/types/permissions"; import { suggestKnowledgePoints } from "./ai-suggest"; import type { ActionState, LessonPlanDocument } from "./types"; export async function suggestKnowledgePointsAction(input: { doc: LessonPlanDocument; textbookId?: string; chapterId?: string; }): Promise> { try { await requirePermission(Permissions.LESSON_PLAN_READ); await requirePermission(Permissions.AI_CHAT); const suggestions = await suggestKnowledgePoints(input.doc, input.textbookId, input.chapterId); return { success: true, data: { suggestions } }; } catch (e) { if (e instanceof PermissionDeniedError) return { success: false, message: e.message }; return { success: false, message: "AI 推荐失败,请检查 AI Provider 配置" }; } } ``` - [ ] **Step 3: 在编辑器顶部加"AI 推荐知识点"按钮(修改 lesson-plan-editor.tsx)** 按钮点击 → 调用 `suggestKnowledgePointsAction` → 弹窗展示推荐列表(含 reason)→ 教师勾选 → 合并到对应 block 的 knowledgePointIds(需让用户选择目标 block 或默认全部相关 block)。 - [ ] **Step 4: 验证 + Commit** Run: `npx tsc --noEmit && npm run lint` Commit: `feat(lesson-preparation): add AI knowledge point suggestion` --- ## Task 15:练习 block — 题库拉取 **Files:** - Create: `src/modules/lesson-preparation/components/question-bank-picker.tsx` - Create: `src/modules/lesson-preparation/components/blocks/exercise-block.tsx` - [ ] **Step 1: 创建 question-bank-picker.tsx(复用 questions 查询)** ```tsx "use client"; import { useEffect, useState } from "react"; import { getQuestionsAction } from "@/modules/questions/actions"; import { Button } from "@/shared/components/ui/button"; import { X } from "lucide-react"; import type { ExerciseItem } from "../../types"; interface Props { onPick: (items: ExerciseItem[]) => void; onClose: () => void; existingIds: string[]; } export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) { const [questions, setQuestions] = useState([]); const [picked, setPicked] = useState([]); const [filters, setFilters] = useState({ knowledgePointId: "", type: "", difficulty: "" }); useEffect(() => { getQuestionsAction(filters).then((res) => { if (res.success && res.data) setQuestions(res.data.items ?? res.data); }); }, [filters]); function add(q: any) { if (existingIds.includes(q.id) || picked.some((p) => p.questionId === q.id)) return; setPicked((prev) => [...prev, { questionId: q.id, source: "bank", score: 5, order: prev.length, }]); } return (

从题库选择题目

{questions.map((q) => (
{JSON.stringify(q.content).slice(0, 80)}
))}
已选 {picked.length} 题
); } ``` > 注意:需核对 `getQuestionsAction` 的签名与返回结构,按实际调整。 - [ ] **Step 2: 创建 exercise-block.tsx** ```tsx "use client"; import { useState } from "react"; import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor"; import { QuestionBankPicker } from "../question-bank-picker"; import { InlineQuestionEditor } from "../inline-question-editor"; import { Button } from "@/shared/components/ui/button"; import { Plus, Trash2 } from "lucide-react"; import type { ExerciseBlockData, ExerciseItem } from "../../types"; interface Props { blockId: string; data: ExerciseBlockData; } export function ExerciseBlock({ blockId, data }: Props) { const { updateBlock } = useLessonPlanEditor(); const [showBank, setShowBank] = useState(false); const [showInline, setShowInline] = useState(false); function update(patch: Partial) { updateBlock(blockId, { data: { ...data, ...patch } }); } function addBankItems(items: ExerciseItem[]) { const next = [...data.items, ...items]; update({ items: next.map((it, i) => ({ ...it, order: i })) }); } function removeItem(idx: number) { update({ items: data.items.filter((_, i) => i !== idx).map((it, i) => ({ ...it, order: i })) }); } return (
{data.items.length === 0 ? (

暂无题目,点击下方按钮添加

) : (
{data.items.map((item, idx) => (
{item.source === "bank" ? "题库" : "新建"} {item.source === "bank" ? `题目 ${item.questionId.slice(0, 8)}` : "课案内新建题目"} {item.score}分
))}
)}
{showBank && ( i.questionId)} onPick={addBankItems} onClose={() => setShowBank(false)} /> )} {showInline && ( { addBankItems([item]); setShowInline(false); }} onClose={() => setShowInline(false)} /> )}
); } ``` - [ ] **Step 3: 在 block-renderer.tsx 中接入 ExerciseBlock** 将 `block.type === "exercise"` 分支替换为 ``。 - [ ] **Step 4: 验证 + Commit** Run: `npx tsc --noEmit && npm run lint` Commit: `feat(lesson-preparation): add exercise block with question bank picker` --- ## Task 16:练习 block — 课案内新建题目 **Files:** - Create: `src/modules/lesson-preparation/components/inline-question-editor.tsx` - [ ] **Step 1: 创建 inline-question-editor.tsx** ```tsx "use client"; import { useState } from "react"; import { createId } from "@paralleldrive/cuid2"; import { Button } from "@/shared/components/ui/button"; import { X } from "lucide-react"; import type { ExerciseItem, InlineQuestionContent } from "../../types"; interface Props { onAdd: (item: ExerciseItem) => void; onClose: () => void; } export function InlineQuestionEditor({ onAdd, onClose }: Props) { const [type, setType] = useState("single_choice"); const [difficulty, setDifficulty] = useState(3); const [text, setText] = useState(""); const [options, setOptions] = useState(["", ""]); const [correctIdx, setCorrectIdx] = useState(0); const [kpIds, setKpIds] = useState([]); function handleAdd() { if (!text.trim()) { alert("请输入题干"); return; } const content: Record = type === "single_choice" ? { text, options: options.map((o, i) => ({ id: String(i), text: o, isCorrect: i === correctIdx })) } : { text }; const inlineContent: InlineQuestionContent = { content, type, difficulty, knowledgePointIds: kpIds }; const item: ExerciseItem = { questionId: `inline_draft_${createId()}`, source: "inline", score: 5, order: 0, inlineContent, }; onAdd(item); } return (

新建题目(课案内)