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

2938 lines
104 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 备课模块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 张表定义**
```typescript
// --- Lesson Preparation (备课) ---
export const lessonPlans = mysqlTable("lesson_plans", {
id: id("id").primaryKey(),
title: varchar("title", { length: 255 }).notNull(),
textbookId: varchar("textbook_id", { length: 128 }).references(() => textbooks.id),
chapterId: varchar("chapter_id", { length: 128 }).references(() => chapters.id),
coursePlanItemId: varchar("course_plan_item_id", { length: 128 }),
subjectId: varchar("subject_id", { length: 128 }).references(() => subjects.id),
gradeId: varchar("grade_id", { length: 128 }).references(() => grades.id),
templateId: varchar("template_id", { length: 128 }),
templateName: varchar("template_name", { length: 100 }),
content: json("content").notNull(),
status: varchar("status", { length: 50 }).default("draft").notNull(),
creatorId: varchar("creator_id", { length: 128 }).notNull().references(() => users.id),
lastSavedAt: timestamp("last_saved_at"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
creatorIdx: index("lp_creator_idx").on(table.creatorId),
statusIdx: index("lp_status_idx").on(table.status),
textbookChapterIdx: index("lp_textbook_chapter_idx").on(table.textbookId, table.chapterId),
subjectGradeIdx: index("lp_subject_grade_idx").on(table.subjectId, table.gradeId),
}));
export const lessonPlanVersions = mysqlTable("lesson_plan_versions", {
id: id("id").primaryKey(),
planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }),
versionNo: int("version_no").notNull(),
label: varchar("label", { length: 100 }),
content: json("content").notNull(),
isAuto: boolean("is_auto").default(false).notNull(),
creatorId: varchar("creator_id", { length: 128 }).notNull().references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (table) => ({
planVersionIdx: uniqueIndex("lpv_plan_version_idx").on(table.planId, table.versionNo),
planCreatedIdx: index("lpv_plan_created_idx").on(table.planId, table.createdAt),
}));
export const lessonPlanTemplates = mysqlTable("lesson_plan_templates", {
id: id("id").primaryKey(),
name: varchar("name", { length: 100 }).notNull(),
type: varchar("type", { length: 50 }).notNull(), // system | personal
scope: varchar("scope", { length: 50 }).notNull(), // regular | review | experiment | inquiry | blank | custom
blocks: json("blocks").notNull(),
creatorId: varchar("creator_id", { length: 128 }).references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
}, (table) => ({
typeCreatorIdx: index("lpt_type_creator_idx").on(table.type, table.creatorId),
}));
```
- [ ] **Step 2: 生成迁移并应用**
Run: `npm run db:generate && npm run db:migrate`
Expected: 生成新迁移文件,迁移成功
- [ ] **Step 3: 验证类型**
Run: `npx tsc --noEmit`
Expected: 无错误
- [ ] **Step 4: Commit**
```bash
git add src/shared/db/schema.ts drizzle/
git commit -m "feat(lesson-preparation): add lesson_plans, lesson_plan_versions, lesson_plan_templates tables"
```
---
## Task 2权限点 + 角色映射
**Files:**
- Modify: `src/shared/types/permissions.ts`
- Modify: `src/shared/lib/permissions.ts`
- [ ] **Step 1: 在 permissions.ts 的 `Permissions` 对象末尾DIAGNOSTIC_READ 之后)新增**
```typescript
// Lesson Plan (备课)
LESSON_PLAN_CREATE: "lesson_plan:create",
LESSON_PLAN_READ: "lesson_plan:read",
LESSON_PLAN_UPDATE: "lesson_plan:update",
LESSON_PLAN_DELETE: "lesson_plan:delete",
LESSON_PLAN_PUBLISH: "lesson_plan:publish",
```
- [ ] **Step 2: 在 permissions.ts 的 ROLE_PERMISSIONS 中admin 和 teacher 数组都追加这 5 个权限**
admin 数组末尾追加:
```typescript
Permissions.LESSON_PLAN_CREATE,
Permissions.LESSON_PLAN_READ,
Permissions.LESSON_PLAN_UPDATE,
Permissions.LESSON_PLAN_DELETE,
Permissions.LESSON_PLAN_PUBLISH,
```
teacher 数组末尾追加同样 5 个。
- [ ] **Step 3: 验证**
Run: `npx tsc --noEmit && npm run lint`
Expected: 无错误
- [ ] **Step 4: Commit**
```bash
git add src/shared/types/permissions.ts src/shared/lib/permissions.ts
git commit -m "feat(lesson-preparation): add 5 lesson_plan permission points"
```
---
## Task 3类型定义 + 常量 + 预置模板
**Files:**
- Create: `src/modules/lesson-preparation/types.ts`
- Create: `src/modules/lesson-preparation/constants.ts`
- [ ] **Step 1: 创建 types.ts**
```typescript
// 课案状态
export type LessonPlanStatus = "draft" | "published" | "archived";
// Block 类型枚举
export type BlockType =
| "objective" | "key_point" | "import" | "new_teaching"
| "consolidation" | "summary" | "homework" | "blackboard"
| "text_study" | "exercise" | "rich_text" | "reflection";
// 富文本类 block 的 data
export interface RichTextBlockData {
html: string;
knowledgePointIds: string[];
}
// 文本研习 block 的 data
export interface TextStudyAnnotation {
id: string;
anchor: { start: number; end: number };
nodeType: string;
title: string;
note: string;
color: "yellow" | "green";
}
export interface TextStudyBlockData {
sourceText: string;
annotations: TextStudyAnnotation[];
knowledgePointIds: string[];
}
// 练习 block 的 data
export type ExercisePurpose = "class_practice" | "after_class_homework";
export interface InlineQuestionContent {
content: unknown; // 与 questions.content 对齐
type: string; // 与 questionTypeEnum 对齐
difficulty: number;
knowledgePointIds: string[];
}
export interface ExerciseItem {
questionId: string; // bank=真实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 套预置模板定义)**
```typescript
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**
```bash
git add src/modules/lesson-preparation/types.ts src/modules/lesson-preparation/constants.ts
git commit -m "feat(lesson-preparation): add types and system template definitions"
```
---
## Task 4Zod 校验 schema
**Files:**
- Create: `src/modules/lesson-preparation/schema.ts`
- [ ] **Step 1: 创建 schema.ts**
```typescript
import { z } from "zod";
export const createLessonPlanSchema = z.object({
title: z.string().min(1, "请输入课案标题").max(255),
textbookId: z.string().optional(),
chapterId: z.string().optional(),
subjectId: z.string().optional(),
gradeId: z.string().optional(),
templateId: z.string().min(1, "请选择模板"),
});
export const updateLessonPlanContentSchema = z.object({
planId: z.string().min(1),
title: z.string().min(1).max(255).optional(),
content: z.unknown(), // Block 文档结构由 types 守卫,运行时只校验存在
});
export const saveVersionSchema = z.object({
planId: z.string().min(1),
label: z.string().max(100).optional(),
});
export const revertVersionSchema = z.object({
planId: z.string().min(1),
versionNo: z.number().int().positive(),
});
export const saveAsTemplateSchema = z.object({
sourcePlanId: z.string().min(1),
name: z.string().min(1).max(100),
});
export type CreateLessonPlanInput = z.infer<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**
```typescript
import "server-only";
import { cache } from "react";
import { and, asc, desc, eq, inArray, like, or, type SQL } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
import { db } from "@/shared/db";
import {
lessonPlans, lessonPlanVersions, lessonPlanTemplates,
textbooks, chapters, subjects, grades, users,
} from "@/shared/db/schema";
import type { DataScope } from "@/shared/types/permissions";
import { SYSTEM_TEMPLATES } from "./constants";
import type {
LessonPlan, LessonPlanListItem, LessonPlanDocument,
LessonPlanTemplate, TemplateBlockSkeleton,
} from "./types";
// ---- 模板初始化:根据 templateId 生成初始 content ----
export function buildInitialContent(blocks: TemplateBlockSkeleton[]): LessonPlanDocument {
return {
version: 1,
blocks: blocks.map((b, i) => ({
id: createId(),
type: b.type,
title: b.title,
data: b.type === "exercise"
? { items: [], purpose: "class_practice", knowledgePointIds: [] }
: b.type === "text_study"
? { sourceText: "", annotations: [], knowledgePointIds: [] }
: { html: "", knowledgePointIds: [] },
order: i,
})),
};
}
// ---- DataScope → 查询条件 ----
function buildScopeCondition(scope: DataScope, userId: string): SQL[] {
switch (scope.type) {
case "all":
return [];
case "owned":
return [eq(lessonPlans.creatorId, userId)];
case "class_taught":
case "grade_managed":
case "class_members":
case "children":
// 教师看自己创建的 + published 的
return [or(eq(lessonPlans.creatorId, userId), eq(lessonPlans.status, "published"))!];
}
}
// ---- 课案列表 ----
export const getLessonPlans = cache(async (
params: {
query?: string;
textbookId?: string;
chapterId?: string;
subjectId?: string;
status?: string;
},
scope: DataScope,
userId: string,
): Promise<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**
```typescript
import "server-only";
import { and, desc, eq, max } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
import { db } from "@/shared/db";
import { lessonPlanVersions, lessonPlans } from "@/shared/db/schema";
import type { LessonPlanDocument, LessonPlanVersion } from "./types";
export async function getLessonPlanVersions(planId: string, userId: string): Promise<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**
```typescript
import "server-only";
import { and, eq } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
import { db } from "@/shared/db";
import { lessonPlanTemplates, lessonPlans } from "@/shared/db/schema";
import { SYSTEM_TEMPLATES } from "./constants";
import type { LessonPlanTemplate, TemplateBlockSkeleton } from "./types";
export async function getLessonPlanTemplates(userId: string): Promise<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**
```typescript
"use server";
import { revalidatePath } from "next/cache";
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
import { Permissions } from "@/shared/types/permissions";
import {
getLessonPlans, getLessonPlanById, createLessonPlan,
updateLessonPlanContent, softDeleteLessonPlan, duplicateLessonPlan,
} from "./data-access";
import {
getLessonPlanVersions, createLessonPlanVersion, revertToVersion, pruneAutoVersions,
} from "./data-access-versions";
import {
getLessonPlanTemplates, saveAsTemplate, deletePersonalTemplate,
} from "./data-access-templates";
import {
createLessonPlanSchema, updateLessonPlanContentSchema,
saveVersionSchema, revertVersionSchema, saveAsTemplateSchema,
} from "./schema";
import type { ActionState, LessonPlanDocument } from "./types";
async function getCtx() {
return await requirePermission(Permissions.LESSON_PLAN_READ);
}
// ---- 课案列表 ----
export async function getLessonPlansAction(params: {
query?: string; textbookId?: string; chapterId?: string;
subjectId?: string; status?: string;
}): Promise<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**
```typescript
"use client";
import { create } from "zustand";
import { createId } from "@paralleldrive/cuid2";
import type { Block, BlockType, LessonPlanDocument } from "../types";
import { BLOCK_TYPE_LABELS } from "../constants";
interface EditorState {
planId: string;
title: string;
doc: LessonPlanDocument;
isDirty: boolean;
isSaving: boolean;
lastSavedAt: number | null;
setTitle: (title: string) => void;
addBlock: (type: BlockType, index?: number) => void;
updateBlock: (id: string, patch: Partial<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 编辑器)**
```tsx
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import { useEffect } from "react";
import type { RichTextBlockData } from "../../types";
interface Props {
blockId: string;
data: RichTextBlockData;
hint?: string;
onUpdate: (data: RichTextBlockData) => void;
}
export function RichTextBlock({ blockId, data, hint, onUpdate }: Props) {
const editor = useEditor({
extensions: [
StarterKit,
Placeholder.configure({ placeholder: hint ?? "输入内容..." }),
],
content: data.html,
onUpdate: ({ editor }) => {
onUpdate({ ...data, html: editor.getHTML() });
},
editorProps: {
attributes: { class: "prose prose-sm max-w-none focus:outline-none min-h-[60px] px-3 py-2" },
},
});
// 外部 content 变化时同步(如版本回退)
useEffect(() => {
if (editor && !editor.isDestroyed && data.html !== editor.getHTML()) {
editor.commands.setContent(data.html, false);
}
}, [data.html, editor]);
return <EditorContent editor={editor} />;
}
```
- [ ] **Step 2: 创建 block-renderer.tsx分发 + 拖拽容器)**
```tsx
"use client";
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, Trash2, ChevronUp, ChevronDown } from "lucide-react";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { RICH_TEXT_BLOCK_TYPES } from "../constants";
import { RichTextBlock } from "./blocks/rich-text-block";
import type { Block } from "../types";
function SortableBlock({ block, index, total }: { block: Block; index: number; total: number }) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: block.id });
const { updateBlock, removeBlock, moveBlock } = useLessonPlanEditor();
const style = { transform: CSS.Transform.toString(transform), transition };
const isRichText = RICH_TEXT_BLOCK_TYPES.includes(block.type);
return (
<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主壳 + 自动保存)**
```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**
```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**
```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**
```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>
);
}
```
```tsx
// lesson-plan-filters.tsx
"use client";
import { useTransition } from "react";
interface Props {
onFilter: (params: { query?: string; subjectId?: string; status?: string }) => void;
subjects: { id: string; name: string }[];
}
export function LessonPlanFilters({ onFilter, subjects }: Props) {
const [isPending, startTransition] = useTransition();
return (
<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>
);
}
```
```tsx
// lesson-plan-list.tsx
"use client";
import { useState } from "react";
import { LessonPlanCard } from "./lesson-plan-card";
import { LessonPlanFilters } from "./lesson-plan-filters";
import { getLessonPlansAction } from "../actions";
import type { LessonPlanListItem } from "../types";
interface Props {
initialItems: LessonPlanListItem[];
subjects: { id: string; name: string }[];
}
export function LessonPlanList({ initialItems, subjects }: Props) {
const [items, setItems] = useState(initialItems);
async function handleFilter(params: { query?: string; subjectId?: string; status?: string }) {
const res = await getLessonPlansAction(params);
if (res.success && res.data) setItems(res.data.items);
}
return (
<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**
```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**
```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**
```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 导航数组中(适当位置,如"课程计划"附近)新增:
```typescript
{
title: "备课",
href: "/teacher/lesson-plans",
icon: "BookOpen", // 或项目使用的图标方案
permission: Permissions.LESSON_PLAN_READ,
},
```
> 需先读取 navigation.ts 确认现有结构与图标方案,再精确插入。
- [ ] **Step 5: 验证 + Commit**
Run: `npx tsc --noEmit && npm run lint`
Commit: `feat(lesson-preparation): add routes and navigation entry`
---
## Task 12seed 系统预设模板 + 架构图同步
**Files:**
- Create: `src/modules/lesson-preparation/seed-templates.ts`
- Modify: `scripts/seed.ts`
- [ ] **Step 1: 创建 seed-templates.ts**
> 系统预设模板在 `constants.ts` 的 `SYSTEM_TEMPLATES` 中以固定 ID 定义,`data-access.ts` 的 `getTemplateById` 优先匹配内存定义。因此**无需写入 DB**seed 脚本仅做幂等性校验(可选)。
```typescript
import "server-only";
import { SYSTEM_TEMPLATES } from "./constants";
// 系统模板以内存常量形式存在(固定 ID无需 DB seed。
// 此函数供 scripts/seed.ts 调用以保持调用约定一致,当前为空操作。
export async function seedLessonPlanTemplates(): Promise<void> {
// 预留:若未来需要将 system 模板落库以便管理后台编辑,在此实现。
void SYSTEM_TEMPLATES;
return;
}
```
- [ ] **Step 2: 在 scripts/seed.ts 末尾调用(可选,保持约定)**
在 seed.ts 末尾其他 seed 调用之后追加:
```typescript
import { seedLessonPlanTemplates } from "../src/modules/lesson-preparation/seed-templates";
// ... 在 main() 末尾
await seedLessonPlanTemplates();
console.log("✓ Lesson plan templates seeded");
```
- [ ] **Step 3: 同步架构图 004**
`docs/architecture/004_architecture_impact_map.md`
- §1.1 分层架构图 modules 行新增 `lesson-preparation`
- §1.2 新增 lesson-preparation 节点(依赖 textbooks/questions/exams/homework/classes/files全部 ───▶ data-access 合理依赖)
- 第二部分新增 §2.27 lesson-preparation 模块清单
- 附录 A 依赖矩阵新增一行一列
- [ ] **Step 4: 同步架构图 005**
`docs/architecture/005_architecture_data.json`
- `modules.lesson_preparation`:完整节点
- `dbTables`:新增 3 张表
- `permissions`:新增 5 个权限点
- `routes`:新增 3 个路由
- `dependencyMatrix`:新增依赖
- [ ] **Step 5: 验证 + Commit**
Run: `npx tsc --noEmit && npm run lint`
Commit: `feat(lesson-preparation): add seed stub and sync architecture docs`
---
# 阶段 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 知识点树查询)**
```tsx
"use client";
import { useEffect, useState } from "react";
import { getKnowledgePointsAction } from "@/modules/textbooks/actions";
import { Button } from "@/shared/components/ui/button";
import { Check, X } from "lucide-react";
interface Props {
textbookId?: string;
chapterId?: string;
selectedIds: string[];
onChange: (ids: string[]) => void;
onClose: () => void;
}
export function KnowledgePointPicker({ textbookId, chapterId, selectedIds, onChange, onClose }: Props) {
const [kps, setKps] = useState<{ id: string; name: string; chapterTitle?: string }[]>([]);
const [local, setLocal] = useState<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.ts` 的 `getKnowledgePointsAction` 签名与返回结构,按实际调整。
- [ ] **Step 2: 在 rich-text-block.tsx 增加 KP 标注入口**
在 RichTextBlock 组件的编辑器下方追加知识点 chip 区 + "标注知识点"按钮,点击弹出 KnowledgePointPicker确认后调用 `onUpdate({ ...data, knowledgePointIds: newIds })`
- [ ] **Step 3: 验证 + Commit**
Run: `npx tsc --noEmit && npm run lint`
Commit: `feat(lesson-preparation): add knowledge point picker and annotation`
---
## Task 14AI 知识点推荐
**Files:**
- Create: `src/modules/lesson-preparation/ai-suggest.ts`
- Create: `src/modules/lesson-preparation/actions-ai.ts`
- [ ] **Step 1: 创建 ai-suggest.ts**
```typescript
import "server-only";
import { createAiChatCompletion } from "@/shared/lib/ai";
import { getKnowledgePoints } from "@/modules/textbooks/data-access";
import type { LessonPlanDocument } from "./types";
export async function suggestKnowledgePoints(
doc: LessonPlanDocument,
textbookId?: string,
chapterId?: string,
): Promise<{ id: string; name: string; reason: string }[]> {
// 1. 提取课案纯文本
const text = doc.blocks
.map((b) => {
const d = b.data as { html?: string; sourceText?: string };
return d.html ?? d.sourceText ?? "";
})
.join("\n")
.slice(0, 3000);
if (!text.trim()) return [];
// 2. 获取候选知识点池
if (!textbookId) return [];
const allKps = await getKnowledgePoints({ textbookId, chapterId });
if (allKps.length === 0) return [];
const kpList = allKps.map((kp) => ({ id: kp.id, name: kp.name })).slice(0, 100);
// 3. 调用 AI
const prompt = `你是教学设计助手。以下是教师备课内容:
---
${text}
---
请从下列知识点中推荐最相关的 3-8 个,并说明理由。返回 JSON 数组,每项含 id/name/reason。
候选知识点:${JSON.stringify(kpList)}`;
const { content } = await createAiChatCompletion({
messages: [{ role: "user", content: prompt }],
responseFormat: "json",
});
try {
const parsed = JSON.parse(content) as { id: string; name: string; reason: string }[];
// 过滤掉不在候选池中的 id
const validIds = new Set(kpList.map((k) => k.id));
return parsed.filter((p) => validIds.has(p.id));
} catch {
return [];
}
}
```
> 注意:需核对 `getKnowledgePoints` 的签名与 `createAiChatCompletion` 的参数格式responseFormat 是否支持),按实际调整。
- [ ] **Step 2: 创建 actions-ai.ts**
```typescript
"use server";
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
import { Permissions } from "@/shared/types/permissions";
import { suggestKnowledgePoints } from "./ai-suggest";
import type { ActionState, LessonPlanDocument } from "./types";
export async function suggestKnowledgePointsAction(input: {
doc: LessonPlanDocument;
textbookId?: string;
chapterId?: string;
}): Promise<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 查询)**
```tsx
"use client";
import { useEffect, useState } from "react";
import { getQuestionsAction } from "@/modules/questions/actions";
import { Button } from "@/shared/components/ui/button";
import { X } from "lucide-react";
import type { ExerciseItem } from "../../types";
interface Props {
onPick: (items: ExerciseItem[]) => void;
onClose: () => void;
existingIds: string[];
}
export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
const [questions, setQuestions] = useState<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**
```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**
```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**
```typescript
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**
```typescript
"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**
```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 加发布按钮 + 溯源徽章**
```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设计稿画布形态的简化版**
```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**
```tsx
"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**
```typescript
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**
```tsx
// 替换对应分支
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 + 同步架构图**
```bash
git add src/modules/lesson-preparation/
git commit -m "feat(lesson-preparation): add text-study block, reflection block, knowledge reverse-lookup"
```
更新 `docs/architecture/004``005` 中 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>``LessonPlanDocument``Block``ExerciseBlockData` 等类型在 Task 3 定义,后续 Task 一致使用。
---
## Execution Handoff
计划已保存至 `docs/superpowers/plans/2026-06-18-lesson-preparation.md`。采用 Inline Executionexecuting-plans skill按 Task 顺序实施,每个 Task 完成后运行 lint + typecheck 验证。