Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
主要变更: - 新增 lesson-preparation 模块: 备课编辑器、节点编辑、AI 建议、知识点选择、版本历史、作业发布 - 新增 shared 通用组件: charts/question-bank-filters/schedule-list/ui (chip-nav/filter-bar/page-header/stat-card/stat-item) - 新增 student/admin 端 loading.tsx 与 error.tsx, 优化加载与错误态体验 - 新增 teacher/lesson-plans 页面 (列表/新建/编辑) - 新增 drizzle 迁移 0002_tiny_lionheart 及 snapshot - 新增 textbooks/schema.ts 与 exams/utils/normalize-structure.ts - 修复 Tiptap v3 SSR hydration 崩溃 (rich-text-block immediatelyRender: false) - 重构多模块 data-access/actions/组件, 修复权限校验与类型规范 - 同步架构文档 004/005 反映新增模块、导出、依赖关系 - 归档 bugs/* 测试报告与 e2e 测试脚本 (admin/parent/student/teacher web_test)
2938 lines
104 KiB
Markdown
2938 lines
104 KiB
Markdown
# 备课模块(lesson-preparation)实施计划
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 实现 P0(课案地基:数据模型+模板+Block编辑器+版本管理+我的课案库)与 P1(联动:知识点标注+题目创建/拉取+作业发布闭环),构成"备课→出题→下发"最小可用闭环。
|
||
|
||
**Architecture:** 新建 `src/modules/lesson-preparation/` 模块,严格三层架构(actions→data-access→shared),零跨模块直查。Block 文档以 JSON 存于 `lesson_plans.content`,版本快照存于 `lesson_plan_versions`。作业发布复用 exam 中转(课案练习→exam 草稿→homework assignment),零 schema 侵入。
|
||
|
||
**Tech Stack:** Next.js 16 App Router · Drizzle ORM (MySQL) · Tiptap(富文本 block)· @dnd-kit(拖拽排序)· zustand(编辑器状态)· react-hook-form + zod · @paralleldrive/cuid2 · vitest
|
||
|
||
**Spec:** `docs/feature/f_bk_design.md`
|
||
|
||
---
|
||
|
||
## 文件结构总览
|
||
|
||
### 新建文件
|
||
|
||
```
|
||
src/shared/db/schema.ts # 修改:新增 3 张表
|
||
src/shared/types/permissions.ts # 修改:新增 5 个权限点
|
||
src/shared/lib/permissions.ts # 修改:teacher/admin 角色映射
|
||
|
||
src/modules/lesson-preparation/
|
||
├─ types.ts # Block 联合类型 + 课案/版本/模板类型
|
||
├─ constants.ts # block 类型枚举 + 状态常量 + 预置模板定义
|
||
├─ schema.ts # Zod 校验
|
||
├─ data-access.ts # 课案 CRUD
|
||
├─ data-access-versions.ts # 版本快照
|
||
├─ data-access-templates.ts # 模板 CRUD
|
||
├─ data-access-knowledge.ts # 知识点/题目反查(P1)
|
||
├─ actions.ts # Server Actions(P0 基础 + 模板)
|
||
├─ actions-publish.ts # 发布作业 Action(P1)
|
||
├─ actions-ai.ts # AI 推荐 Action(P1)
|
||
├─ publish-service.ts # 发布编排(P1)
|
||
├─ ai-suggest.ts # AI 知识点推荐(P1)
|
||
├─ seed-templates.ts # 系统预设模板 seed
|
||
├─ hooks/
|
||
│ └─ use-lesson-plan-editor.ts # 编辑器状态(zustand)
|
||
└─ components/
|
||
├─ lesson-plan-list.tsx # 我的课案库列表
|
||
├─ lesson-plan-card.tsx # 课案卡片
|
||
├─ lesson-plan-filters.tsx # 筛选器
|
||
├─ lesson-plan-editor.tsx # 编辑器主壳
|
||
├─ block-renderer.tsx # block 分发渲染
|
||
├─ blocks/
|
||
│ ├─ rich-text-block.tsx # 富文本类 block
|
||
│ ├─ text-study-block.tsx # 文本研习画布(P1)
|
||
│ ├─ exercise-block.tsx # 练习/作业 block(P1)
|
||
│ └─ reflection-block.tsx # 教学反思(P1 简单渲染)
|
||
├─ template-picker.tsx # 模板选择器
|
||
├─ version-history-drawer.tsx # 版本历史
|
||
├─ knowledge-point-picker.tsx # 知识点选择器(P1)
|
||
├─ question-bank-picker.tsx # 题库拉取侧栏(P1)
|
||
├─ inline-question-editor.tsx # 课案内新建题目(P1)
|
||
└─ publish-homework-dialog.tsx # 发布作业弹窗(P1)
|
||
|
||
src/app/(dashboard)/teacher/lesson-plans/
|
||
├─ page.tsx # 课案库列表页
|
||
├─ new/page.tsx # 新建课案(选模板)
|
||
└─ [planId]/edit/page.tsx # 编辑器页
|
||
|
||
src/modules/layout/config/navigation.ts # 修改:teacher 菜单加"备课"
|
||
scripts/seed.ts # 修改:调用 seed-templates
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段划分
|
||
|
||
- **阶段 A(P0 地基)**:Task 1-10,先落地,构成可用的课案编辑器
|
||
- **阶段 B(P1 联动)**:Task 11-18,在 P0 基础上接通题库与作业发布
|
||
|
||
---
|
||
|
||
# 阶段 A:P0 地基
|
||
|
||
## Task 1:数据模型 — 新增 3 张表
|
||
|
||
**Files:**
|
||
- Modify: `src/shared/db/schema.ts`(末尾追加)
|
||
|
||
- [ ] **Step 1: 在 schema.ts 末尾追加 3 张表定义**
|
||
|
||
```typescript
|
||
// --- Lesson Preparation (备课) ---
|
||
|
||
export const lessonPlans = mysqlTable("lesson_plans", {
|
||
id: id("id").primaryKey(),
|
||
title: varchar("title", { length: 255 }).notNull(),
|
||
textbookId: varchar("textbook_id", { length: 128 }).references(() => textbooks.id),
|
||
chapterId: varchar("chapter_id", { length: 128 }).references(() => chapters.id),
|
||
coursePlanItemId: varchar("course_plan_item_id", { length: 128 }),
|
||
subjectId: varchar("subject_id", { length: 128 }).references(() => subjects.id),
|
||
gradeId: varchar("grade_id", { length: 128 }).references(() => grades.id),
|
||
templateId: varchar("template_id", { length: 128 }),
|
||
templateName: varchar("template_name", { length: 100 }),
|
||
content: json("content").notNull(),
|
||
status: varchar("status", { length: 50 }).default("draft").notNull(),
|
||
creatorId: varchar("creator_id", { length: 128 }).notNull().references(() => users.id),
|
||
lastSavedAt: timestamp("last_saved_at"),
|
||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
|
||
}, (table) => ({
|
||
creatorIdx: index("lp_creator_idx").on(table.creatorId),
|
||
statusIdx: index("lp_status_idx").on(table.status),
|
||
textbookChapterIdx: index("lp_textbook_chapter_idx").on(table.textbookId, table.chapterId),
|
||
subjectGradeIdx: index("lp_subject_grade_idx").on(table.subjectId, table.gradeId),
|
||
}));
|
||
|
||
export const lessonPlanVersions = mysqlTable("lesson_plan_versions", {
|
||
id: id("id").primaryKey(),
|
||
planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }),
|
||
versionNo: int("version_no").notNull(),
|
||
label: varchar("label", { length: 100 }),
|
||
content: json("content").notNull(),
|
||
isAuto: boolean("is_auto").default(false).notNull(),
|
||
creatorId: varchar("creator_id", { length: 128 }).notNull().references(() => users.id),
|
||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||
}, (table) => ({
|
||
planVersionIdx: uniqueIndex("lpv_plan_version_idx").on(table.planId, table.versionNo),
|
||
planCreatedIdx: index("lpv_plan_created_idx").on(table.planId, table.createdAt),
|
||
}));
|
||
|
||
export const lessonPlanTemplates = mysqlTable("lesson_plan_templates", {
|
||
id: id("id").primaryKey(),
|
||
name: varchar("name", { length: 100 }).notNull(),
|
||
type: varchar("type", { length: 50 }).notNull(), // system | personal
|
||
scope: varchar("scope", { length: 50 }).notNull(), // regular | review | experiment | inquiry | blank | custom
|
||
blocks: json("blocks").notNull(),
|
||
creatorId: varchar("creator_id", { length: 128 }).references(() => users.id),
|
||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
|
||
}, (table) => ({
|
||
typeCreatorIdx: index("lpt_type_creator_idx").on(table.type, table.creatorId),
|
||
}));
|
||
```
|
||
|
||
- [ ] **Step 2: 生成迁移并应用**
|
||
|
||
Run: `npm run db:generate && npm run db:migrate`
|
||
Expected: 生成新迁移文件,迁移成功
|
||
|
||
- [ ] **Step 3: 验证类型**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Expected: 无错误
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/shared/db/schema.ts drizzle/
|
||
git commit -m "feat(lesson-preparation): add lesson_plans, lesson_plan_versions, lesson_plan_templates tables"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2:权限点 + 角色映射
|
||
|
||
**Files:**
|
||
- Modify: `src/shared/types/permissions.ts`
|
||
- Modify: `src/shared/lib/permissions.ts`
|
||
|
||
- [ ] **Step 1: 在 permissions.ts 的 `Permissions` 对象末尾(DIAGNOSTIC_READ 之后)新增**
|
||
|
||
```typescript
|
||
// Lesson Plan (备课)
|
||
LESSON_PLAN_CREATE: "lesson_plan:create",
|
||
LESSON_PLAN_READ: "lesson_plan:read",
|
||
LESSON_PLAN_UPDATE: "lesson_plan:update",
|
||
LESSON_PLAN_DELETE: "lesson_plan:delete",
|
||
LESSON_PLAN_PUBLISH: "lesson_plan:publish",
|
||
```
|
||
|
||
- [ ] **Step 2: 在 permissions.ts 的 ROLE_PERMISSIONS 中,admin 和 teacher 数组都追加这 5 个权限**
|
||
|
||
admin 数组末尾追加:
|
||
```typescript
|
||
Permissions.LESSON_PLAN_CREATE,
|
||
Permissions.LESSON_PLAN_READ,
|
||
Permissions.LESSON_PLAN_UPDATE,
|
||
Permissions.LESSON_PLAN_DELETE,
|
||
Permissions.LESSON_PLAN_PUBLISH,
|
||
```
|
||
|
||
teacher 数组末尾追加同样 5 个。
|
||
|
||
- [ ] **Step 3: 验证**
|
||
|
||
Run: `npx tsc --noEmit && npm run lint`
|
||
Expected: 无错误
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/shared/types/permissions.ts src/shared/lib/permissions.ts
|
||
git commit -m "feat(lesson-preparation): add 5 lesson_plan permission points"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3:类型定义 + 常量 + 预置模板
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/types.ts`
|
||
- Create: `src/modules/lesson-preparation/constants.ts`
|
||
|
||
- [ ] **Step 1: 创建 types.ts**
|
||
|
||
```typescript
|
||
// 课案状态
|
||
export type LessonPlanStatus = "draft" | "published" | "archived";
|
||
|
||
// Block 类型枚举
|
||
export type BlockType =
|
||
| "objective" | "key_point" | "import" | "new_teaching"
|
||
| "consolidation" | "summary" | "homework" | "blackboard"
|
||
| "text_study" | "exercise" | "rich_text" | "reflection";
|
||
|
||
// 富文本类 block 的 data
|
||
export interface RichTextBlockData {
|
||
html: string;
|
||
knowledgePointIds: string[];
|
||
}
|
||
|
||
// 文本研习 block 的 data
|
||
export interface TextStudyAnnotation {
|
||
id: string;
|
||
anchor: { start: number; end: number };
|
||
nodeType: string;
|
||
title: string;
|
||
note: string;
|
||
color: "yellow" | "green";
|
||
}
|
||
export interface TextStudyBlockData {
|
||
sourceText: string;
|
||
annotations: TextStudyAnnotation[];
|
||
knowledgePointIds: string[];
|
||
}
|
||
|
||
// 练习 block 的 data
|
||
export type ExercisePurpose = "class_practice" | "after_class_homework";
|
||
export interface InlineQuestionContent {
|
||
content: unknown; // 与 questions.content 对齐
|
||
type: string; // 与 questionTypeEnum 对齐
|
||
difficulty: number;
|
||
knowledgePointIds: string[];
|
||
}
|
||
export interface ExerciseItem {
|
||
questionId: string; // bank=真实ID;inline=占位 inline_draft_xxx
|
||
source: "bank" | "inline";
|
||
score: number;
|
||
order: number;
|
||
inlineContent?: InlineQuestionContent; // 仅 inline
|
||
}
|
||
export interface ExerciseBlockData {
|
||
items: ExerciseItem[];
|
||
purpose: ExercisePurpose;
|
||
knowledgePointIds: string[];
|
||
publishedAssignmentId?: string;
|
||
publishedExamId?: string;
|
||
publishedAt?: string;
|
||
}
|
||
|
||
// Block 联合
|
||
export interface Block {
|
||
id: string;
|
||
type: BlockType;
|
||
title: string;
|
||
data: RichTextBlockData | TextStudyBlockData | ExerciseBlockData;
|
||
order: number;
|
||
}
|
||
|
||
// 文档
|
||
export interface LessonPlanDocument {
|
||
version: 1;
|
||
blocks: Block[];
|
||
}
|
||
|
||
// 课案
|
||
export interface LessonPlan {
|
||
id: string;
|
||
title: string;
|
||
textbookId: string | null;
|
||
chapterId: string | null;
|
||
coursePlanItemId: string | null;
|
||
subjectId: string | null;
|
||
gradeId: string | null;
|
||
templateId: string | null;
|
||
templateName: string | null;
|
||
content: LessonPlanDocument;
|
||
status: LessonPlanStatus;
|
||
creatorId: string;
|
||
lastSavedAt: string | null;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
}
|
||
|
||
// 版本
|
||
export interface LessonPlanVersion {
|
||
id: string;
|
||
planId: string;
|
||
versionNo: number;
|
||
label: string | null;
|
||
content: LessonPlanDocument;
|
||
isAuto: boolean;
|
||
creatorId: string;
|
||
createdAt: string;
|
||
}
|
||
|
||
// 模板
|
||
export type TemplateType = "system" | "personal";
|
||
export type TemplateScope = "regular" | "review" | "experiment" | "inquiry" | "blank" | "custom";
|
||
export interface TemplateBlockSkeleton {
|
||
type: BlockType;
|
||
title: string;
|
||
hint?: string;
|
||
}
|
||
export interface LessonPlanTemplate {
|
||
id: string;
|
||
name: string;
|
||
type: TemplateType;
|
||
scope: TemplateScope;
|
||
blocks: TemplateBlockSkeleton[];
|
||
creatorId: string | null;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
}
|
||
|
||
// 列表项(带教材/章节名)
|
||
export interface LessonPlanListItem extends LessonPlan {
|
||
textbookTitle: string | null;
|
||
chapterTitle: string | null;
|
||
subjectName: string | null;
|
||
gradeName: string | null;
|
||
creatorName: string | null;
|
||
}
|
||
|
||
// ActionState(与项目现有约定一致)
|
||
export type ActionState<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 4:Zod 校验 schema
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/schema.ts`
|
||
|
||
- [ ] **Step 1: 创建 schema.ts**
|
||
|
||
```typescript
|
||
import { z } from "zod";
|
||
|
||
export const createLessonPlanSchema = z.object({
|
||
title: z.string().min(1, "请输入课案标题").max(255),
|
||
textbookId: z.string().optional(),
|
||
chapterId: z.string().optional(),
|
||
subjectId: z.string().optional(),
|
||
gradeId: z.string().optional(),
|
||
templateId: z.string().min(1, "请选择模板"),
|
||
});
|
||
|
||
export const updateLessonPlanContentSchema = z.object({
|
||
planId: z.string().min(1),
|
||
title: z.string().min(1).max(255).optional(),
|
||
content: z.unknown(), // Block 文档结构由 types 守卫,运行时只校验存在
|
||
});
|
||
|
||
export const saveVersionSchema = z.object({
|
||
planId: z.string().min(1),
|
||
label: z.string().max(100).optional(),
|
||
});
|
||
|
||
export const revertVersionSchema = z.object({
|
||
planId: z.string().min(1),
|
||
versionNo: z.number().int().positive(),
|
||
});
|
||
|
||
export const saveAsTemplateSchema = z.object({
|
||
sourcePlanId: z.string().min(1),
|
||
name: z.string().min(1).max(100),
|
||
});
|
||
|
||
export type CreateLessonPlanInput = z.infer<typeof createLessonPlanSchema>;
|
||
export type UpdateLessonPlanContentInput = z.infer<typeof updateLessonPlanContentSchema>;
|
||
```
|
||
|
||
- [ ] **Step 2: 验证 + Commit**
|
||
|
||
Run: `npx tsc --noEmit`
|
||
Commit: `feat(lesson-preparation): add zod schemas`
|
||
|
||
---
|
||
|
||
## Task 5:data-access 层(课案 CRUD)
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/data-access.ts`
|
||
|
||
- [ ] **Step 1: 创建 data-access.ts**
|
||
|
||
```typescript
|
||
import "server-only";
|
||
|
||
import { cache } from "react";
|
||
import { and, asc, desc, eq, inArray, like, or, type SQL } from "drizzle-orm";
|
||
import { createId } from "@paralleldrive/cuid2";
|
||
|
||
import { db } from "@/shared/db";
|
||
import {
|
||
lessonPlans, lessonPlanVersions, lessonPlanTemplates,
|
||
textbooks, chapters, subjects, grades, users,
|
||
} from "@/shared/db/schema";
|
||
import type { DataScope } from "@/shared/types/permissions";
|
||
import { SYSTEM_TEMPLATES } from "./constants";
|
||
import type {
|
||
LessonPlan, LessonPlanListItem, LessonPlanDocument,
|
||
LessonPlanTemplate, TemplateBlockSkeleton,
|
||
} from "./types";
|
||
|
||
// ---- 模板初始化:根据 templateId 生成初始 content ----
|
||
export function buildInitialContent(blocks: TemplateBlockSkeleton[]): LessonPlanDocument {
|
||
return {
|
||
version: 1,
|
||
blocks: blocks.map((b, i) => ({
|
||
id: createId(),
|
||
type: b.type,
|
||
title: b.title,
|
||
data: b.type === "exercise"
|
||
? { items: [], purpose: "class_practice", knowledgePointIds: [] }
|
||
: b.type === "text_study"
|
||
? { sourceText: "", annotations: [], knowledgePointIds: [] }
|
||
: { html: "", knowledgePointIds: [] },
|
||
order: i,
|
||
})),
|
||
};
|
||
}
|
||
|
||
// ---- DataScope → 查询条件 ----
|
||
function buildScopeCondition(scope: DataScope, userId: string): SQL[] {
|
||
switch (scope.type) {
|
||
case "all":
|
||
return [];
|
||
case "owned":
|
||
return [eq(lessonPlans.creatorId, userId)];
|
||
case "class_taught":
|
||
case "grade_managed":
|
||
case "class_members":
|
||
case "children":
|
||
// 教师看自己创建的 + published 的
|
||
return [or(eq(lessonPlans.creatorId, userId), eq(lessonPlans.status, "published"))!];
|
||
}
|
||
}
|
||
|
||
// ---- 课案列表 ----
|
||
export const getLessonPlans = cache(async (
|
||
params: {
|
||
query?: string;
|
||
textbookId?: string;
|
||
chapterId?: string;
|
||
subjectId?: string;
|
||
status?: string;
|
||
},
|
||
scope: DataScope,
|
||
userId: string,
|
||
): Promise<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: "",
|
||
};
|
||
}
|
||
// 再查 DB(personal 模板)
|
||
const rows = await db.select().from(lessonPlanTemplates).where(eq(lessonPlanTemplates.id, templateId)).limit(1);
|
||
return rows.length > 0 ? rows[0] as unknown as LessonPlanTemplate : null;
|
||
}
|
||
```
|
||
|
||
> 注意:`ne` 需从 drizzle-orm 导入;若项目无 `ne`,用 `sql\`${lessonPlans.status} != 'archived'\`` 替代。
|
||
|
||
- [ ] **Step 2: 验证 + Commit**
|
||
|
||
Run: `npx tsc --noEmit && npm run lint`
|
||
Commit: `feat(lesson-preparation): add lesson plan data-access (CRUD)`
|
||
|
||
---
|
||
|
||
## Task 6:data-access-versions + data-access-templates
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/data-access-versions.ts`
|
||
- Create: `src/modules/lesson-preparation/data-access-templates.ts`
|
||
|
||
- [ ] **Step 1: 创建 data-access-versions.ts**
|
||
|
||
```typescript
|
||
import "server-only";
|
||
|
||
import { and, desc, eq, max } from "drizzle-orm";
|
||
import { createId } from "@paralleldrive/cuid2";
|
||
|
||
import { db } from "@/shared/db";
|
||
import { lessonPlanVersions, lessonPlans } from "@/shared/db/schema";
|
||
import type { LessonPlanDocument, LessonPlanVersion } from "./types";
|
||
|
||
export async function getLessonPlanVersions(planId: string, userId: string): Promise<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 7:Server Actions(P0 基础)
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/actions.ts`
|
||
|
||
- [ ] **Step 1: 创建 actions.ts**
|
||
|
||
```typescript
|
||
"use server";
|
||
|
||
import { revalidatePath } from "next/cache";
|
||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
|
||
import { Permissions } from "@/shared/types/permissions";
|
||
import {
|
||
getLessonPlans, getLessonPlanById, createLessonPlan,
|
||
updateLessonPlanContent, softDeleteLessonPlan, duplicateLessonPlan,
|
||
} from "./data-access";
|
||
import {
|
||
getLessonPlanVersions, createLessonPlanVersion, revertToVersion, pruneAutoVersions,
|
||
} from "./data-access-versions";
|
||
import {
|
||
getLessonPlanTemplates, saveAsTemplate, deletePersonalTemplate,
|
||
} from "./data-access-templates";
|
||
import {
|
||
createLessonPlanSchema, updateLessonPlanContentSchema,
|
||
saveVersionSchema, revertVersionSchema, saveAsTemplateSchema,
|
||
} from "./schema";
|
||
import type { ActionState, LessonPlanDocument } from "./types";
|
||
|
||
async function getCtx() {
|
||
return await requirePermission(Permissions.LESSON_PLAN_READ);
|
||
}
|
||
|
||
// ---- 课案列表 ----
|
||
export async function getLessonPlansAction(params: {
|
||
query?: string; textbookId?: string; chapterId?: string;
|
||
subjectId?: string; status?: string;
|
||
}): Promise<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:编辑器状态 Hook(zustand)
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts`
|
||
|
||
- [ ] **Step 1: 创建 hook**
|
||
|
||
```typescript
|
||
"use client";
|
||
|
||
import { create } from "zustand";
|
||
import { createId } from "@paralleldrive/cuid2";
|
||
import type { Block, BlockType, LessonPlanDocument } from "../types";
|
||
import { BLOCK_TYPE_LABELS } from "../constants";
|
||
|
||
interface EditorState {
|
||
planId: string;
|
||
title: string;
|
||
doc: LessonPlanDocument;
|
||
isDirty: boolean;
|
||
isSaving: boolean;
|
||
lastSavedAt: number | null;
|
||
|
||
setTitle: (title: string) => void;
|
||
addBlock: (type: BlockType, index?: number) => void;
|
||
updateBlock: (id: string, patch: Partial<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 9:Block 组件(富文本类 + 渲染分发)
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/components/blocks/rich-text-block.tsx`
|
||
- Create: `src/modules/lesson-preparation/components/block-renderer.tsx`
|
||
|
||
- [ ] **Step 1: 创建 rich-text-block.tsx(Tiptap 编辑器)**
|
||
|
||
```tsx
|
||
"use client";
|
||
|
||
import { useEditor, EditorContent } from "@tiptap/react";
|
||
import StarterKit from "@tiptap/starter-kit";
|
||
import Placeholder from "@tiptap/extension-placeholder";
|
||
import { useEffect } from "react";
|
||
import type { RichTextBlockData } from "../../types";
|
||
|
||
interface Props {
|
||
blockId: string;
|
||
data: RichTextBlockData;
|
||
hint?: string;
|
||
onUpdate: (data: RichTextBlockData) => void;
|
||
}
|
||
|
||
export function RichTextBlock({ blockId, data, hint, onUpdate }: Props) {
|
||
const editor = useEditor({
|
||
extensions: [
|
||
StarterKit,
|
||
Placeholder.configure({ placeholder: hint ?? "输入内容..." }),
|
||
],
|
||
content: data.html,
|
||
onUpdate: ({ editor }) => {
|
||
onUpdate({ ...data, html: editor.getHTML() });
|
||
},
|
||
editorProps: {
|
||
attributes: { class: "prose prose-sm max-w-none focus:outline-none min-h-[60px] px-3 py-2" },
|
||
},
|
||
});
|
||
|
||
// 外部 content 变化时同步(如版本回退)
|
||
useEffect(() => {
|
||
if (editor && !editor.isDestroyed && data.html !== editor.getHTML()) {
|
||
editor.commands.setContent(data.html, false);
|
||
}
|
||
}, [data.html, editor]);
|
||
|
||
return <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 12:seed 系统预设模板 + 架构图同步
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/seed-templates.ts`
|
||
- Modify: `scripts/seed.ts`
|
||
|
||
- [ ] **Step 1: 创建 seed-templates.ts**
|
||
|
||
> 系统预设模板在 `constants.ts` 的 `SYSTEM_TEMPLATES` 中以固定 ID 定义,`data-access.ts` 的 `getTemplateById` 优先匹配内存定义。因此**无需写入 DB**,seed 脚本仅做幂等性校验(可选)。
|
||
|
||
```typescript
|
||
import "server-only";
|
||
import { SYSTEM_TEMPLATES } from "./constants";
|
||
|
||
// 系统模板以内存常量形式存在(固定 ID),无需 DB seed。
|
||
// 此函数供 scripts/seed.ts 调用以保持调用约定一致,当前为空操作。
|
||
export async function seedLessonPlanTemplates(): Promise<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`
|
||
|
||
---
|
||
|
||
# 阶段 B:P1 联动
|
||
|
||
## Task 13:知识点选择器 + 标注
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/components/knowledge-point-picker.tsx`
|
||
- Modify: `src/modules/lesson-preparation/components/blocks/rich-text-block.tsx`(加 KP 标注入口)
|
||
|
||
- [ ] **Step 1: 创建 knowledge-point-picker.tsx(复用 textbooks 知识点树查询)**
|
||
|
||
```tsx
|
||
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { getKnowledgePointsAction } from "@/modules/textbooks/actions";
|
||
import { Button } from "@/shared/components/ui/button";
|
||
import { Check, X } from "lucide-react";
|
||
|
||
interface Props {
|
||
textbookId?: string;
|
||
chapterId?: string;
|
||
selectedIds: string[];
|
||
onChange: (ids: string[]) => void;
|
||
onClose: () => void;
|
||
}
|
||
|
||
export function KnowledgePointPicker({ textbookId, chapterId, selectedIds, onChange, onClose }: Props) {
|
||
const [kps, setKps] = useState<{ id: string; name: string; chapterTitle?: string }[]>([]);
|
||
const [local, setLocal] = useState<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 14:AI 知识点推荐
|
||
|
||
**Files:**
|
||
- Create: `src/modules/lesson-preparation/ai-suggest.ts`
|
||
- Create: `src/modules/lesson-preparation/actions-ai.ts`
|
||
|
||
- [ ] **Step 1: 创建 ai-suggest.ts**
|
||
|
||
```typescript
|
||
import "server-only";
|
||
import { createAiChatCompletion } from "@/shared/lib/ai";
|
||
import { getKnowledgePoints } from "@/modules/textbooks/data-access";
|
||
import type { LessonPlanDocument } from "./types";
|
||
|
||
export async function suggestKnowledgePoints(
|
||
doc: LessonPlanDocument,
|
||
textbookId?: string,
|
||
chapterId?: string,
|
||
): Promise<{ id: string; name: string; reason: string }[]> {
|
||
// 1. 提取课案纯文本
|
||
const text = doc.blocks
|
||
.map((b) => {
|
||
const d = b.data as { html?: string; sourceText?: string };
|
||
return d.html ?? d.sourceText ?? "";
|
||
})
|
||
.join("\n")
|
||
.slice(0, 3000);
|
||
|
||
if (!text.trim()) return [];
|
||
|
||
// 2. 获取候选知识点池
|
||
if (!textbookId) return [];
|
||
const allKps = await getKnowledgePoints({ textbookId, chapterId });
|
||
if (allKps.length === 0) return [];
|
||
|
||
const kpList = allKps.map((kp) => ({ id: kp.id, name: kp.name })).slice(0, 100);
|
||
|
||
// 3. 调用 AI
|
||
const prompt = `你是教学设计助手。以下是教师备课内容:
|
||
---
|
||
${text}
|
||
---
|
||
请从下列知识点中推荐最相关的 3-8 个,并说明理由。返回 JSON 数组,每项含 id/name/reason。
|
||
候选知识点:${JSON.stringify(kpList)}`;
|
||
|
||
const { content } = await createAiChatCompletion({
|
||
messages: [{ role: "user", content: prompt }],
|
||
responseFormat: "json",
|
||
});
|
||
|
||
try {
|
||
const parsed = JSON.parse(content) as { id: string; name: string; reason: string }[];
|
||
// 过滤掉不在候选池中的 id
|
||
const validIds = new Set(kpList.map((k) => k.id));
|
||
return parsed.filter((p) => validIds.has(p.id));
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
```
|
||
|
||
> 注意:需核对 `getKnowledgePoints` 的签名与 `createAiChatCompletion` 的参数格式(responseFormat 是否支持),按实际调整。
|
||
|
||
- [ ] **Step 2: 创建 actions-ai.ts**
|
||
|
||
```typescript
|
||
"use server";
|
||
|
||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
|
||
import { Permissions } from "@/shared/types/permissions";
|
||
import { suggestKnowledgePoints } from "./ai-suggest";
|
||
import type { ActionState, LessonPlanDocument } from "./types";
|
||
|
||
export async function suggestKnowledgePointsAction(input: {
|
||
doc: LessonPlanDocument;
|
||
textbookId?: string;
|
||
chapterId?: string;
|
||
}): Promise<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 block(purpose=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 Execution(executing-plans skill)按 Task 顺序实施,每个 Task 完成后运行 lint + typecheck 验证。
|