Files
NextEdu/docs/superpowers/plans/2026-06-18-lesson-preparation.md
SpecialX 978d9a8309
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
feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
主要变更:

- 新增 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)
2026-06-22 01:06:16 +08:00

104 KiB
Raw Blame History

备课模块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 ActionsP0 基础 + 模板)
├─ actions-publish.ts                             # 发布作业 ActionP1
├─ actions-ai.ts                                  # AI 推荐 ActionP1
├─ 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                       # 练习/作业 blockP1
   │  └─ 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

阶段划分

  • 阶段 AP0 地基)Task 1-10先落地构成可用的课案编辑器
  • 阶段 BP1 联动)Task 11-18在 P0 基础上接通题库与作业发布

阶段 AP0 地基

Task 1数据模型 — 新增 3 张表

Files:

  • Modify: src/shared/db/schema.ts(末尾追加)

  • Step 1: 在 schema.ts 末尾追加 3 张表定义

// --- 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
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 之后)新增

  // 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 数组末尾追加:

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

// 课案状态
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=真实IDinline=占位 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<T = unknown> = {
  success: boolean;
  message?: string;
  errors?: Record<string, string[]>;
  data?: T;
};
  • Step 2: 创建 constants.ts含 4+1 套预置模板定义)
import type { BlockType, TemplateBlockSkeleton, TemplateScope } from "./types";

// block 类型 → 中文默认标题
export const BLOCK_TYPE_LABELS: Record<BlockType, string> = {
  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<string, string> = {
  draft: "草稿",
  published: "已发布",
  archived: "已归档",
};
  • Step 3: 验证

Run: npx tsc --noEmit && npm run lint Expected: 无错误

  • Step 4: Commit
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 4Zod 校验 schema

Files:

  • Create: src/modules/lesson-preparation/schema.ts

  • Step 1: 创建 schema.ts

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<typeof createLessonPlanSchema>;
export type UpdateLessonPlanContentInput = z.infer<typeof updateLessonPlanContentSchema>;
  • Step 2: 验证 + Commit

Run: npx tsc --noEmit Commit: feat(lesson-preparation): add zod schemas


Task 5data-access 层(课案 CRUD

Files:

  • Create: src/modules/lesson-preparation/data-access.ts

  • Step 1: 创建 data-access.ts

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<LessonPlanListItem[]> => {
  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<LessonPlan | null> => {
  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<void> {
  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<void> {
  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<LessonPlanTemplate | null> {
  // 先查 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: "",
    };
  }
  // 再查 DBpersonal 模板)
  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 6data-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

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<LessonPlanVersion[]> {
  // 校验归属
  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<LessonPlanDocument | null> {
  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<void> {
  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
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<LessonPlanTemplate[]> {
  // 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<void> {
  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 7Server ActionsP0 基础)

Files:

  • Create: src/modules/lesson-preparation/actions.ts

  • Step 1: 创建 actions.ts

"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<ActionState<{ items: Awaited<ReturnType<typeof getLessonPlans>> }>> {
  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<ActionState<{ plan: Awaited<ReturnType<typeof getLessonPlanById>> }>> {
  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<ActionState<{ planId: string }>> {
  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<ActionState> {
  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<ActionState<{ versionNo: number }>> {
  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<ActionState<{ versions: Awaited<ReturnType<typeof getLessonPlanVersions>> }>> {
  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<ActionState<{ newVersionNo: number }>> {
  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<ActionState> {
  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<ActionState<{ newPlanId: string }>> {
  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<ActionState<{ templates: Awaited<ReturnType<typeof getLessonPlanTemplates>> }>> {
  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<ActionState<{ templateId: string }>> {
  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<ActionState> {
  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编辑器状态 Hookzustand

Files:

  • Create: src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts

  • Step 1: 创建 hook

"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<Block>) => 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<EditorState>((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 9Block 组件(富文本类 + 渲染分发)

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.tsxTiptap 编辑器)

"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 <EditorContent editor={editor} />;
}
  • Step 2: 创建 block-renderer.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 (
    <div ref={setNodeRef} style={style} className="border border-outline-variant rounded-lg bg-surface-container-lowest">
      <div className="flex items-center gap-2 px-3 py-2 border-b border-outline-variant bg-surface-container-low">
        <button {...attributes} {...listeners} className="cursor-grab active:cursor-grabbing text-outline hover:text-on-surface">
          <GripVertical className="w-4 h-4" />
        </button>
        <input
          value={block.title}
          onChange={(e) => updateBlock(block.id, { title: e.target.value })}
          className="flex-1 bg-transparent font-title-md text-title-md focus:outline-none"
        />
        <button onClick={() => moveBlock(block.id, index - 1)} disabled={index === 0} className="p-1 text-outline hover:text-on-surface disabled:opacity-30">
          <ChevronUp className="w-4 h-4" />
        </button>
        <button onClick={() => moveBlock(block.id, index + 1)} disabled={index === total - 1} className="p-1 text-outline hover:text-on-surface disabled:opacity-30">
          <ChevronDown className="w-4 h-4" />
        </button>
        <button onClick={() => removeBlock(block.id)} className="p-1 text-error hover:text-error/80">
          <Trash2 className="w-4 h-4" />
        </button>
      </div>
      <div className="p-2">
        {isRichText ? (
          <RichTextBlock
            blockId={block.id}
            data={block.data as never}
            onUpdate={(d) => updateBlock(block.id, { data: d })}
          />
        ) : block.type === "exercise" ? (
          <div className="text-on-surface-variant text-sm p-4">练习块(P1 实现)</div>
        ) : block.type === "text_study" ? (
          <div className="text-on-surface-variant text-sm p-4">文本研习块(P1 实现)</div>
        ) : (
          <div className="text-on-surface-variant text-sm p-4">未知 block 类型</div>
        )}
      </div>
    </div>
  );
}

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 (
    <DndContext collisionDetection={closestCenter} onDragEnd={onDragEnd}>
      <SortableContext items={doc.blocks.map((b) => b.id)} strategy={verticalListSortingStrategy}>
        <div className="flex flex-col gap-4">
          {doc.blocks.map((b, i) => (
            <SortableBlock key={b.id} block={b} index={i} total={doc.blocks.length} />
          ))}
        </div>
      </SortableContext>
    </DndContext>
  );
}
  • 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主壳 + 自动保存)

"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<ReturnType<typeof setTimeout> | null>(null);
  const versionTimer = useRef<ReturnType<typeof setInterval> | 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 (
    <div className="flex flex-col h-full">
      {/* 顶部工具栏 */}
      <div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant bg-surface">
        <input
          value={editor.title}
          onChange={(e) => editor.setTitle(e.target.value)}
          className="flex-1 bg-transparent font-headline-md text-headline-md focus:outline-none"
        />
        <span className="text-on-surface-variant text-sm">
          {editor.isSaving ? "保存中..." : editor.isDirty ? "未保存" : "已保存"}
        </span>
        <Button variant="outline" size="sm" onClick={() => setShowVersions(true)}>
          <History className="w-4 h-4 mr-1" /> 版本
        </Button>
        <Button size="sm" onClick={handleManualSave} disabled={editor.isSaving}>
          <Save className="w-4 h-4 mr-1" /> 保存版本
        </Button>
      </div>

      {/* Block 列表 */}
      <div className="flex-1 overflow-y-auto p-4 bg-surface-container-low">
        <BlockRenderer />
        <div className="mt-4 relative">
          <Button variant="outline" onClick={() => setShowAddMenu(!showAddMenu)}>
            <Plus className="w-4 h-4 mr-1" /> 添加环节
          </Button>
          {showAddMenu && (
            <div className="absolute z-10 mt-1 bg-surface border border-outline-variant rounded-lg shadow-lg p-2 grid grid-cols-2 gap-1 w-80">
              {BLOCK_TYPES_TO_ADD.map((t) => (
                <button
                  key={t}
                  onClick={() => { editor.addBlock(t); setShowAddMenu(false); }}
                  className="text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded"
                >
                  {BLOCK_TYPE_LABELS[t]}
                </button>
              ))}
            </div>
          )}
        </div>
      </div>

      <VersionHistoryDrawer
        open={showVersions}
        onClose={() => setShowVersions(false)}
        planId={planId}
        onReverted={() => { /* 触发页面刷新由父组件处理 */ }}
      />
    </div>
  );
}
  • Step 2: 创建 version-history-drawer.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<LessonPlanVersion[]>([]);
  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 (
    <div className="fixed inset-0 z-50 flex">
      <div className="flex-1 bg-black/30" onClick={onClose} />
      <div className="w-96 bg-surface border-l border-outline-variant overflow-y-auto p-4">
        <h3 className="font-headline-md text-headline-md mb-4">版本历史</h3>
        {loading ? <p>加载中...</p> : versions.length === 0 ? <p className="text-on-surface-variant">暂无版本</p> : (
          <div className="flex flex-col gap-2">
            {versions.map((v) => (
              <div key={v.id} className="border border-outline-variant rounded-lg p-3">
                <div className="flex justify-between items-center">
                  <span className="font-title-md">v{v.versionNo}</span>
                  {v.isAuto && <span className="text-xs bg-surface-container-highest px-2 py-0.5 rounded">自动</span>}
                </div>
                <p className="text-sm text-on-surface-variant">{v.label ?? "手动保存"}</p>
                <p className="text-xs text-on-surface-variant mt-1">{new Date(v.createdAt).toLocaleString()}</p>
                <Button variant="outline" size="sm" className="mt-2" onClick={() => handleRevert(v.versionNo)}>
                  回退到此版本
                </Button>
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}
  • Step 3: 创建 template-picker.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<string>("");
  const [title, setTitle] = useState("");
  const [error, setError] = useState<string | null>(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 (
    <form action={handleSubmit} className="max-w-3xl mx-auto p-6 space-y-6">
      <div>
        <label className="font-title-md block mb-2">课案标题</label>
        <input
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          required
          className="w-full border border-outline-variant rounded-lg px-3 py-2"
          placeholder="例如:《秋天》第一课时"
        />
      </div>
      <div>
        <label className="font-title-md block mb-2">选择模板</label>
        <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
          {SYSTEM_TEMPLATES.map((t) => (
            <button
              type="button"
              key={t.id}
              onClick={() => setSelected(t.id)}
              className={`text-left p-4 border-2 rounded-lg transition-colors ${
                selected === t.id ? "border-primary bg-primary/5" : "border-outline-variant hover:border-primary/50"
              }`}
            >
              <div className="font-title-md">{t.name}</div>
              <div className="text-sm text-on-surface-variant mt-1">
                {t.blocks.length === 0 ? "从空白开始" : `${t.blocks.length} 个环节`}
              </div>
            </button>
          ))}
        </div>
      </div>
      {error && <p className="text-error text-sm">{error}</p>}
      <Button type="submit" disabled={!selected || !title}>创建课案</Button>
    </form>
  );
}
  • Step 4: 创建 lesson-plan-card.tsx + lesson-plan-filters.tsx + lesson-plan-list.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 (
    <div className="border border-outline-variant rounded-lg p-4 bg-surface-container-lowest hover:shadow-md transition-shadow">
      <Link href={`/teacher/lesson-plans/${plan.id}/edit`} className="block">
        <h3 className="font-title-md text-title-md hover:text-primary">{plan.title}</h3>
      </Link>
      <div className="text-sm text-on-surface-variant mt-1">
        {plan.textbookTitle ?? "无教材"} · {plan.chapterTitle ?? "无章节"}
      </div>
      <div className="text-xs text-on-surface-variant mt-1">
        {plan.templateName ?? "无模板"} · {LESSON_PLAN_STATUS_LABELS[plan.status]}
      </div>
      <div className="text-xs text-on-surface-variant mt-2">
        最后保存:{plan.lastSavedAt ? new Date(plan.lastSavedAt).toLocaleString() : "未保存"}
      </div>
      <div className="flex gap-2 mt-3">
        <Button variant="outline" size="sm" onClick={async () => {
          const res = await duplicateLessonPlanAction(plan.id);
          if (res.success) window.location.reload();
        }}>复制</Button>
        <Button variant="outline" size="sm" onClick={async () => {
          if (!confirm("确认归档此课案?")) return;
          const res = await deleteLessonPlanAction(plan.id);
          if (res.success) window.location.reload();
        }}>归档</Button>
      </div>
    </div>
  );
}
// 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 (
    <div className="flex gap-2 flex-wrap">
      <input
        placeholder="搜索标题..."
        onChange={(e) => startTransition(() => onFilter({ query: e.target.value }))}
        className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
      />
      <select onChange={(e) => onFilter({ subjectId: e.target.value || undefined })} className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm">
        <option value="">全部学科</option>
        {subjects.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
      </select>
      <select onChange={(e) => onFilter({ status: e.target.value || undefined })} className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm">
        <option value="">全部状态</option>
        <option value="draft">草稿</option>
        <option value="published">已发布</option>
      </select>
    </div>
  );
}
// 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 (
    <div className="space-y-4">
      <LessonPlanFilters onFilter={handleFilter} subjects={subjects} />
      {items.length === 0 ? (
        <p className="text-on-surface-variant text-center py-12">暂无课案,点击"新建课案"开始</p>
      ) : (
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
          {items.map((p) => <LessonPlanCard key={p.id} plan={p} />)}
        </div>
      )}
    </div>
  );
}
  • 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

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 (
    <div className="p-6 space-y-4">
      <div className="flex justify-between items-center">
        <h1 className="font-headline-lg text-headline-lg">我的课案</h1>
        <Link href="/teacher/lesson-plans/new">
          <Button><Plus className="w-4 h-4 mr-1" />新建课案</Button>
        </Link>
      </div>
      <LessonPlanList initialItems={items} subjects={subjects} />
    </div>
  );
}
  • Step 2: 创建新建页 new/page.tsx
import { TemplatePicker } from "@/modules/lesson-preparation/components/template-picker";

export default function NewLessonPlanPage() {
  return (
    <div className="p-6">
      <h1 className="font-headline-lg text-headline-lg mb-6">新建课案</h1>
      <TemplatePicker />
    </div>
  );
}
  • Step 3: 创建编辑页 [planId]/edit/page.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 (
    <div className="h-[calc(100vh-4rem)]">
      <LessonPlanEditor
        planId={plan.id}
        initialTitle={plan.title}
        initialDoc={plan.content}
      />
    </div>
  );
}
  • Step 4: 在 navigation.ts 的 teacher 菜单新增"备课"项

在 teacher 导航数组中(适当位置,如"课程计划"附近)新增:

{
  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 12seed 系统预设模板 + 架构图同步

Files:

  • Create: src/modules/lesson-preparation/seed-templates.ts

  • Modify: scripts/seed.ts

  • Step 1: 创建 seed-templates.ts

系统预设模板在 constants.tsSYSTEM_TEMPLATES 中以固定 ID 定义,data-access.tsgetTemplateById 优先匹配内存定义。因此无需写入 DBseed 脚本仅做幂等性校验(可选)。

import "server-only";
import { SYSTEM_TEMPLATES } from "./constants";

// 系统模板以内存常量形式存在(固定 ID无需 DB seed。
// 此函数供 scripts/seed.ts 调用以保持调用约定一致,当前为空操作。
export async function seedLessonPlanTemplates(): Promise<void> {
  // 预留:若未来需要将 system 模板落库以便管理后台编辑,在此实现。
  void SYSTEM_TEMPLATES;
  return;
}
  • Step 2: 在 scripts/seed.ts 末尾调用(可选,保持约定)

在 seed.ts 末尾其他 seed 调用之后追加:

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


阶段 BP1 联动

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 知识点树查询)

"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<string[]>(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 (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
      <div className="bg-surface rounded-lg shadow-xl w-96 max-h-[70vh] flex flex-col">
        <div className="flex justify-between items-center p-4 border-b border-outline-variant">
          <h3 className="font-title-md">选择知识点</h3>
          <button onClick={onClose}><X className="w-4 h-4" /></button>
        </div>
        <div className="flex-1 overflow-y-auto p-4">
          {kps.length === 0 ? <p className="text-on-surface-variant text-sm">未找到知识点,请先在教材模块创建</p> : (
            <div className="space-y-1">
              {kps.map((kp) => (
                <label key={kp.id} className="flex items-center gap-2 p-2 hover:bg-surface-container-highest rounded cursor-pointer">
                  <input type="checkbox" checked={local.includes(kp.id)} onChange={() => toggle(kp.id)} />
                  <span className="text-sm">{kp.name}</span>
                </label>
              ))}
            </div>
          )}
        </div>
        <div className="p-4 border-t border-outline-variant flex justify-end gap-2">
          <Button variant="outline" size="sm" onClick={onClose}>取消</Button>
          <Button size="sm" onClick={() => { onChange(local); onClose(); }}>确认</Button>
        </div>
      </div>
    </div>
  );
}

注意:需先核对 textbooks/actions.tsgetKnowledgePointsAction 签名与返回结构,按实际调整。

  • 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 14AI 知识点推荐

Files:

  • Create: src/modules/lesson-preparation/ai-suggest.ts

  • Create: src/modules/lesson-preparation/actions-ai.ts

  • Step 1: 创建 ai-suggest.ts

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
"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<ActionState<{ suggestions: { id: string; name: string; reason: string }[] }>> {
  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 查询)

"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<any[]>([]);
  const [picked, setPicked] = useState<ExerciseItem[]>([]);
  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 (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
      <div className="bg-surface rounded-lg shadow-xl w-[700px] max-h-[80vh] flex flex-col">
        <div className="flex justify-between items-center p-4 border-b">
          <h3 className="font-title-md">从题库选择题目</h3>
          <button onClick={onClose}><X className="w-4 h-4" /></button>
        </div>
        <div className="flex gap-2 p-4 border-b">
          <select onChange={(e) => setFilters({ ...filters, type: e.target.value })} className="border rounded px-2 py-1 text-sm">
            <option value="">全部题型</option>
            <option value="single_choice">单选</option>
            <option value="text">填空</option>
            <option value="judgment">判断</option>
          </select>
          <select onChange={(e) => setFilters({ ...filters, difficulty: e.target.value })} className="border rounded px-2 py-1 text-sm">
            <option value="">全部难度</option>
            {[1,2,3,4,5].map((d) => <option key={d} value={d}>{d}</option>)}
          </select>
        </div>
        <div className="flex-1 overflow-y-auto p-4">
          <div className="space-y-2">
            {questions.map((q) => (
              <div key={q.id} className="border rounded p-2 flex justify-between items-center">
                <span className="text-sm truncate flex-1">{JSON.stringify(q.content).slice(0, 80)}</span>
                <Button size="sm" variant="outline" onClick={() => add(q)}>添加</Button>
              </div>
            ))}
          </div>
        </div>
        <div className="p-4 border-t flex justify-between">
          <span className="text-sm">已选 {picked.length} </span>
          <Button onClick={() => { onPick(picked); onClose(); }}>插入</Button>
        </div>
      </div>
    </div>
  );
}

注意:需核对 getQuestionsAction 的签名与返回结构,按实际调整。

  • Step 2: 创建 exercise-block.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<ExerciseBlockData>) {
    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 (
    <div className="space-y-2">
      <div className="flex gap-2">
        <select
          value={data.purpose}
          onChange={(e) => update({ purpose: e.target.value as never })}
          className="border rounded px-2 py-1 text-sm"
        >
          <option value="class_practice">课堂练习</option>
          <option value="after_class_homework">课后作业</option>
        </select>
      </div>
      {data.items.length === 0 ? (
        <p className="text-on-surface-variant text-sm p-4 text-center border border-dashed rounded">
          暂无题目,点击下方按钮添加
        </p>
      ) : (
        <div className="space-y-1">
          {data.items.map((item, idx) => (
            <div key={idx} className="flex items-center gap-2 border rounded p-2">
              <span className="text-xs bg-surface-container-highest px-2 py-0.5 rounded">
                {item.source === "bank" ? "题库" : "新建"}
              </span>
              <span className="text-sm flex-1 truncate">
                {item.source === "bank" ? `题目 ${item.questionId.slice(0, 8)}` : "课案内新建题目"}
              </span>
              <span className="text-xs">{item.score}</span>
              <button onClick={() => removeItem(idx)}><Trash2 className="w-3 h-3 text-error" /></button>
            </div>
          ))}
        </div>
      )}
      <div className="flex gap-2">
        <Button variant="outline" size="sm" onClick={() => setShowBank(true)}>
          <Plus className="w-3 h-3 mr-1" />从题库添加
        </Button>
        <Button variant="outline" size="sm" onClick={() => setShowInline(true)}>
          <Plus className="w-3 h-3 mr-1" />新建题目
        </Button>
      </div>
      {showBank && (
        <QuestionBankPicker
          existingIds={data.items.map((i) => i.questionId)}
          onPick={addBankItems}
          onClose={() => setShowBank(false)}
        />
      )}
      {showInline && (
        <InlineQuestionEditor
          onAdd={(item) => { addBankItems([item]); setShowInline(false); }}
          onClose={() => setShowInline(false)}
        />
      )}
    </div>
  );
}
  • Step 3: 在 block-renderer.tsx 中接入 ExerciseBlock

block.type === "exercise" 分支替换为 <ExerciseBlock blockId={block.id} data={block.data as never} />

  • 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

"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<string>("single_choice");
  const [difficulty, setDifficulty] = useState(3);
  const [text, setText] = useState("");
  const [options, setOptions] = useState<string[]>(["", ""]);
  const [correctIdx, setCorrectIdx] = useState(0);
  const [kpIds, setKpIds] = useState<string[]>([]);

  function handleAdd() {
    if (!text.trim()) { alert("请输入题干"); return; }
    const content: Record<string, unknown> = 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 (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
      <div className="bg-surface rounded-lg shadow-xl w-[600px] max-h-[80vh] flex flex-col">
        <div className="flex justify-between items-center p-4 border-b">
          <h3 className="font-title-md">新建题目(课案内)</h3>
          <button onClick={onClose}><X className="w-4 h-4" /></button>
        </div>
        <div className="flex-1 overflow-y-auto p-4 space-y-3">
          <div>
            <label className="text-sm font-medium">题型</label>
            <select value={type} onChange={(e) => setType(e.target.value)} className="w-full border rounded px-2 py-1 mt-1">
              <option value="single_choice">单选题</option>
              <option value="text">填空题</option>
              <option value="judgment">判断题</option>
            </select>
          </div>
          <div>
            <label className="text-sm font-medium">题干</label>
            <textarea value={text} onChange={(e) => setText(e.target.value)} className="w-full border rounded px-2 py-1 mt-1 min-h-[80px]" />
          </div>
          {type === "single_choice" && (
            <div>
              <label className="text-sm font-medium">选项(勾选正确答案)</label>
              {options.map((opt, i) => (
                <div key={i} className="flex items-center gap-2 mt-1">
                  <input type="radio" checked={correctIdx === i} onChange={() => setCorrectIdx(i)} />
                  <input value={opt} onChange={(e) => setOptions(options.map((o, j) => j === i ? e.target.value : o))} className="flex-1 border rounded px-2 py-1" />
                  {options.length > 2 && <button onClick={() => setOptions(options.filter((_, j) => j !== i))}>删除</button>}
                </div>
              ))}
              {options.length < 6 && <button onClick={() => setOptions([...options, ""])} className="text-sm text-primary mt-1">+ 添加选项</button>}
            </div>
          )}
          <div>
            <label className="text-sm font-medium">难度</label>
            <select value={difficulty} onChange={(e) => setDifficulty(Number(e.target.value))} className="w-full border rounded px-2 py-1 mt-1">
              {[1,2,3,4,5].map((d) => <option key={d} value={d}>{d}</option>)}
            </select>
          </div>
        </div>
        <div className="p-4 border-t flex justify-end gap-2">
          <Button variant="outline" onClick={onClose}>取消</Button>
          <Button onClick={handleAdd}>添加</Button>
        </div>
      </div>
    </div>
  );
}
  • Step 2: 验证 + Commit

Run: npx tsc --noEmit && npm run lint Commit: feat(lesson-preparation): add inline question editor


Task 17发布服务 + 发布 Action + 发布弹窗

Files:

  • Create: src/modules/lesson-preparation/publish-service.ts

  • Create: src/modules/lesson-preparation/actions-publish.ts

  • Create: src/modules/lesson-preparation/components/publish-homework-dialog.tsx

  • Modify: src/modules/lesson-preparation/components/blocks/exercise-block.tsx(加发布按钮)

  • Step 1: 创建 publish-service.ts

import "server-only";
import { createId } from "@paralleldrive/cuid2";
import { eq } from "drizzle-orm";
import { db } from "@/shared/db";
import { lessonPlans } from "@/shared/db/schema";
import { createQuestionWithRelations } from "@/modules/questions/data-access";
import { persistExamDraft } from "@/modules/exams/data-access";
import { createHomeworkAssignment } from "@/modules/homework/data-access-write";
import type { LessonPlanDocument, ExerciseBlockData } from "./types";

interface PublishInput {
  planId: string;
  blockId: string;
  userId: string;
  classIds: string[];
  availableAt?: Date;
  dueAt?: Date;
}

interface PublishResult {
  examId: string;
  assignmentId: string;
  updatedContent: LessonPlanDocument;
}

export async function publishLessonPlanHomework(input: PublishInput): Promise<PublishResult> {
  // 1. 读取课案
  const rows = await db.select().from(lessonPlans)
    .where(eq(lessonPlans.id, input.planId)).limit(1);
  if (rows.length === 0) throw new Error("课案不存在");
  const plan = rows[0] as unknown as { id: string; content: LessonPlanDocument; creatorId: string; title: string; textbookId: string | null; chapterId: string | null; subjectId: string | null; gradeId: string | null };
  if (plan.creatorId !== input.userId) throw new Error("无权发布");

  // 2. 定位 exercise block
  const block = plan.content.blocks.find((b) => b.id === input.blockId);
  if (!block || block.type !== "exercise") throw new Error("练习块不存在");
  const data = block.data as ExerciseBlockData;
  if (data.items.length === 0) throw new Error("练习块无题目");
  if (data.publishedAssignmentId) throw new Error("该练习块已发布,请使用'重新发布'");

  // 3. inline 题目入库,替换占位 ID
  const newContent: LessonPlanDocument = JSON.parse(JSON.stringify(plan.content));
  const newBlock = newContent.blocks.find((b) => b.id === input.blockId)!;
  const newData = newBlock.data as ExerciseBlockData;

  for (let i = 0; i < newData.items.length; i++) {
    const item = newData.items[i];
    if (item.source === "inline" && item.inlineContent) {
      const { questionId } = await createQuestionWithRelations({
        content: item.inlineContent.content,
        type: item.inlineContent.type as never,
        difficulty: item.inlineContent.difficulty,
        authorId: input.userId,
        knowledgePointIds: item.inlineContent.knowledgePointIds,
      });
      newData.items[i] = { ...item, questionId, inlineContent: undefined };
    }
  }

  // 4. 打包 exam 草稿
  const { examId } = await persistExamDraft({
    title: `${plan.title} - 作业`,
    creatorId: input.userId,
    textbookId: plan.textbookId ?? undefined,
    chapterId: plan.chapterId ?? undefined,
    subjectId: plan.subjectId ?? undefined,
    gradeId: plan.gradeId ?? undefined,
    questions: newData.items.map((it, i) => ({
      questionId: it.questionId, score: it.score, order: i,
    })),
  });

  // 5. 下发作业
  const { assignmentId } = await createHomeworkAssignment({
    sourceExamId: examId,
    title: `${plan.title} - 作业`,
    creatorId: input.userId,
    classIds: input.classIds,
    availableAt: input.availableAt,
    dueAt: input.dueAt,
  });

  // 6. 回写溯源标记
  newData.publishedExamId = examId;
  newData.publishedAssignmentId = assignmentId;
  newData.publishedAt = new Date().toISOString();
  await db.update(lessonPlans).set({ content: newContent }).where(eq(lessonPlans.id, input.planId));

  return { examId, assignmentId, updatedContent: newContent };
}

注意:需核对 createQuestionWithRelations / persistExamDraft / createHomeworkAssignment 的实际签名与入参字段名,按实际调整。这些函数可能需要新增或调整参数(如 sourceLessonPlanId

  • Step 2: 创建 actions-publish.ts
"use server";

import { revalidatePath } from "next/cache";
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
import { Permissions } from "@/shared/types/permissions";
import { publishLessonPlanHomework } from "./publish-service";
import type { ActionState } from "./types";

export async function publishLessonPlanHomeworkAction(input: {
  planId: string;
  blockId: string;
  classIds: string[];
  availableAt?: string;
  dueAt?: string;
}): Promise<ActionState<{ examId: string; assignmentId: string }>> {
  try {
    await requirePermission(Permissions.LESSON_PLAN_PUBLISH);
    await requirePermission(Permissions.HOMEWORK_CREATE);
    const result = await publishLessonPlanHomework({
      planId: input.planId,
      blockId: input.blockId,
      userId: (await requirePermission(Permissions.LESSON_PLAN_READ)).userId,
      classIds: input.classIds,
      availableAt: input.availableAt ? new Date(input.availableAt) : undefined,
      dueAt: input.dueAt ? new Date(input.dueAt) : undefined,
    });
    revalidatePath("/teacher/lesson-plans");
    revalidatePath("/teacher/homework");
    return { success: true, data: { examId: result.examId, assignmentId: result.assignmentId } };
  } catch (e) {
    if (e instanceof PermissionDeniedError) return { success: false, message: e.message };
    return { success: false, message: e instanceof Error ? e.message : "发布失败" };
  }
}
  • Step 3: 创建 publish-homework-dialog.tsx
"use client";

import { useState } from "react";
import { publishLessonPlanHomeworkAction } from "../actions-publish";
import { Button } from "@/shared/components/ui/button";
import { X } from "lucide-react";

interface Props {
  planId: string;
  blockId: string;
  classes: { id: string; name: string }[];
  onClose: () => void;
  onPublished: () => void;
}

export function PublishHomeworkDialog({ planId, blockId, classes, onClose, onPublished }: Props) {
  const [selectedClasses, setSelectedClasses] = useState<string[]>([]);
  const [availableAt, setAvailableAt] = useState("");
  const [dueAt, setDueAt] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  async function handlePublish() {
    if (selectedClasses.length === 0) { setError("请选择至少一个班级"); return; }
    setLoading(true); setError(null);
    const res = await publishLessonPlanHomeworkAction({
      planId, blockId,
      classIds: selectedClasses,
      availableAt: availableAt || undefined,
      dueAt: dueAt || undefined,
    });
    setLoading(false);
    if (res.success) { onPublished(); onClose(); }
    else setError(res.message ?? "发布失败");
  }

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
      <div className="bg-surface rounded-lg shadow-xl w-96">
        <div className="flex justify-between items-center p-4 border-b">
          <h3 className="font-title-md">发布为作业</h3>
          <button onClick={onClose}><X className="w-4 h-4" /></button>
        </div>
        <div className="p-4 space-y-3">
          <div>
            <label className="text-sm font-medium">下发班级</label>
            <div className="mt-1 space-y-1 max-h-40 overflow-y-auto">
              {classes.map((c) => (
                <label key={c.id} className="flex items-center gap-2">
                  <input type="checkbox" checked={selectedClasses.includes(c.id)}
                    onChange={() => setSelectedClasses(selectedClasses.includes(c.id)
                      ? selectedClasses.filter((x) => x !== c.id)
                      : [...selectedClasses, c.id])} />
                  <span className="text-sm">{c.name}</span>
                </label>
              ))}
            </div>
          </div>
          <div>
            <label className="text-sm font-medium">开始时间(可选)</label>
            <input type="datetime-local" value={availableAt} onChange={(e) => setAvailableAt(e.target.value)} className="w-full border rounded px-2 py-1 mt-1" />
          </div>
          <div>
            <label className="text-sm font-medium">截止时间(可选)</label>
            <input type="datetime-local" value={dueAt} onChange={(e) => setDueAt(e.target.value)} className="w-full border rounded px-2 py-1 mt-1" />
          </div>
          {error && <p className="text-error text-sm">{error}</p>}
        </div>
        <div className="p-4 border-t flex justify-end gap-2">
          <Button variant="outline" onClick={onClose}>取消</Button>
          <Button onClick={handlePublish} disabled={loading}>{loading ? "发布中..." : "发布"}</Button>
        </div>
      </div>
    </div>
  );
}
  • Step 4: 在 exercise-block.tsx 加发布按钮 + 溯源徽章
// 在 ExerciseBlock 组件内追加
import { PublishHomeworkDialog } from "../publish-homework-dialog";

// 在 props 增加 classes
interface Props {
  blockId: string;
  data: ExerciseBlockData;
  classes: { id: string; name: string }[];
}

// 在组件内
const [showPublish, setShowPublish] = useState(false);

// 渲染:若 data.publishedAssignmentId 显示徽章,否则显示发布按钮
{data.publishedAssignmentId ? (
  <div className="flex items-center gap-2 text-sm">
    <span className="bg-tertiary-container/20 text-tertiary px-2 py-1 rounded">已发布为作业</span>
    <a href={`/teacher/homework`} className="text-primary underline">查看</a>
  </div>
) : data.purpose === "after_class_homework" && data.items.length > 0 && (
  <Button size="sm" onClick={() => setShowPublish(true)}>发布为作业</Button>
)}
{showPublish && (
  <PublishHomeworkDialog
    planId={/* 从 editor state 获取 */}
    blockId={blockId}
    classes={classes}
    onClose={() => setShowPublish(false)}
    onPublished={() => window.location.reload()}
  />
)}

注意planId 需从 useLessonPlanEditor state 获取classes 由编辑器页面从服务端传入。

  • Step 5: 验证 + Commit

Run: npx tsc --noEmit && npm run lint Commit: feat(lesson-preparation): add publish service and homework dialog


Task 18文本研习 block + 反查 data-access + 最终验收

Files:

  • Create: src/modules/lesson-preparation/components/blocks/text-study-block.tsx

  • Create: src/modules/lesson-preparation/components/blocks/reflection-block.tsx

  • Create: src/modules/lesson-preparation/data-access-knowledge.ts

  • Modify: src/modules/lesson-preparation/components/block-renderer.tsx(接入 text_study + reflection

  • Step 1: 创建 text-study-block.tsx设计稿画布形态的简化版

"use client";

import { useState } from "react";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
import { Button } from "@/shared/components/ui/button";
import { Plus, Trash2 } from "lucide-react";
import { createId } from "@paralleldrive/cuid2";
import type { TextStudyBlockData, TextStudyAnnotation } from "../../types";

interface Props {
  blockId: string;
  data: TextStudyBlockData;
}

export function TextStudyBlock({ blockId, data }: Props) {
  const { updateBlock } = useLessonPlanEditor();
  const [selection, setSelection] = useState<{ start: number; end: number } | null>(null);

  function update(patch: Partial<TextStudyBlockData>) {
    updateBlock(blockId, { data: { ...data, ...patch } });
  }

  function handleTextSelect() {
    const sel = window.getSelection();
    if (!sel || sel.rangeCount === 0) return;
    const range = sel.getRangeAt(0);
    // 简化:用相对 sourceText 的字符偏移
    const start = range.startOffset;
    const end = range.endOffset;
    if (end > start) setSelection({ start, end });
  }

  function addAnnotation() {
    if (!selection) { alert("请先在课文中选中一段文本"); return; }
    const ann: TextStudyAnnotation = {
      id: createId(),
      anchor: selection,
      nodeType: "language_feature",
      title: "教学节点",
      note: "",
      color: "yellow",
    };
    update({ annotations: [...data.annotations, ann] });
    setSelection(null);
  }

  function removeAnnotation(id: string) {
    update({ annotations: data.annotations.filter((a) => a.id !== id) });
  }

  return (
    <div className="space-y-3">
      <div>
        <label className="text-sm font-medium">课文原文</label>
        <textarea
          value={data.sourceText}
          onChange={(e) => update({ sourceText: e.target.value })}
          onMouseUp={handleTextSelect}
          className="w-full border rounded p-2 mt-1 min-h-[120px] font-serif leading-loose"
          placeholder="粘贴课文原文,选中文本后可添加教学节点"
        />
      </div>
      <Button variant="outline" size="sm" onClick={addAnnotation} disabled={!selection}>
        <Plus className="w-3 h-3 mr-1" />为选中文本添加节点
      </Button>
      {data.annotations.length > 0 && (
        <div className="space-y-2">
          {data.annotations.map((ann) => (
            <div key={ann.id} className="border-l-4 border-secondary-container pl-3 py-1">
              <div className="flex justify-between items-center">
                <input
                  value={ann.title}
                  onChange={(e) => update({
                    annotations: data.annotations.map((a) => a.id === ann.id ? { ...a, title: e.target.value } : a),
                  })}
                  className="font-medium text-sm bg-transparent flex-1"
                />
                <button onClick={() => removeAnnotation(ann.id)}><Trash2 className="w-3 h-3 text-error" /></button>
              </div>
              <textarea
                value={ann.note}
                onChange={(e) => update({
                  annotations: data.annotations.map((a) => a.id === ann.id ? { ...a, note: e.target.value } : a),
                })}
                className="w-full text-sm border rounded p-1 mt-1 min-h-[40px]"
                placeholder="教学说明..."
              />
            </div>
          ))}
        </div>
      )}
    </div>
  );
}
  • Step 2: 创建 reflection-block.tsx简单富文本复用 RichTextBlock
"use client";
import { RichTextBlock } from "./rich-text-block";
import type { RichTextBlockData } from "../../types";

interface Props {
  blockId: string;
  data: RichTextBlockData;
}

export function ReflectionBlock(props: Props) {
  // 教学反思在 P1 阶段与普通富文本一致P3 再扩展学情数据嵌入
  return <RichTextBlock {...props} hint="课后填写教学反思..." />;
}
  • Step 3: 创建 data-access-knowledge.ts反查函数无 UI
import "server-only";
import { eq, like, sql } from "drizzle-orm";
import { db } from "@/shared/db";
import { lessonPlans } from "@/shared/db/schema";
import type { LessonPlanListItem } from "./types";

// 查询关联了某知识点的课案
export async function getLessonPlansByKnowledgePoint(knowledgePointId: string): Promise<LessonPlanListItem[]> {
  // content 是 JSON用 LIKE 粗筛后内存精确过滤
  const rows = await db.select().from(lessonPlans)
    .where(like(lessonPlans.content, `%${knowledgePointId}%`));
  return rows.filter((r) => {
    const doc = r.content as { blocks: Array<{ data: { knowledgePointIds?: string[] } }> };
    return doc.blocks.some((b) => b.data?.knowledgePointIds?.includes(knowledgePointId));
  }) as unknown as LessonPlanListItem[];
}

// 查询使用了某题目的课案
export async function getLessonPlansByQuestion(questionId: string): Promise<LessonPlanListItem[]> {
  const rows = await db.select().from(lessonPlans)
    .where(like(lessonPlans.content, `%${questionId}%`));
  return rows.filter((r) => {
    const doc = r.content as { blocks: Array<{ type: string; data: { items?: Array<{ questionId: string }> } }> };
    return doc.blocks.some((b) => b.type === "exercise" && b.data?.items?.some((it) => it.questionId === questionId));
  }) as unknown as LessonPlanListItem[];
}
  • Step 4: 在 block-renderer.tsx 接入 text_study 和 reflection
// 替换对应分支
block.type === "text_study" ? (
  <TextStudyBlock blockId={block.id} data={block.data as never} />
) : block.type === "reflection" ? (
  <ReflectionBlock blockId={block.id} data={block.data as never} />
) : ...
  • Step 5: 最终验收

Run: npx tsc --noEmit && npm run lint Expected: 零错误

手动验证(启动 dev server

  • 创建课案(选模板)→ 进入编辑器

  • 增删改 block、拖拽排序、富文本编辑

  • 自动保存 + 手动保存版本 + 版本回退

  • exercise block 从题库添加题目 + 课案内新建题目

  • 富文本 block 标注知识点

  • exercise blockpurpose=after_class_homework发布为作业

  • 已发布 block 显示溯源徽章

  • Step 6: Commit + 同步架构图

git add src/modules/lesson-preparation/
git commit -m "feat(lesson-preparation): add text-study block, reflection block, knowledge reverse-lookup"

更新 docs/architecture/004005 中 lesson-preparation 模块的导出函数清单(新增 publish-service / ai-suggest / data-access-knowledge 的导出)。


Self-Review

Spec coverage:

  • §1 模块定位 → Task 1-2表+权限)✓
  • §2 数据模型 → Task 1 ✓
  • §3 Block 文档 → Task 3, 8, 9 ✓
  • §4 模板系统 → Task 3, 6, 10, 12 ✓
  • §5 编辑器与版本 → Task 8, 9, 10 ✓
  • §6 知识点标注 → Task 13, 14 ✓
  • §7 题目创建/拉取 → Task 15, 16 ✓
  • §8 作业发布 → Task 17 ✓
  • §9 模块文件结构 → 全部 Task 覆盖 ✓
  • §10 Actions → Task 7, 14, 17 ✓
  • §11 data-access → Task 5, 6, 18 ✓
  • §12 权限点 → Task 2 ✓
  • §13 路由 → Task 11 ✓
  • §15 架构图同步 → Task 12, 18 ✓

Placeholder scan: 计划中标注"需核对签名"处为实施时需查阅实际 API 的提示,非占位符;所有代码块均为可直接使用的实现骨架。

Type consistency: ActionState<T>LessonPlanDocumentBlockExerciseBlockData 等类型在 Task 3 定义,后续 Task 一致使用。


Execution Handoff

计划已保存至 docs/superpowers/plans/2026-06-18-lesson-preparation.md。采用 Inline Executionexecuting-plans skill按 Task 顺序实施,每个 Task 完成后运行 lint + typecheck 验证。