feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
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)
This commit is contained in:
SpecialX
2026-06-22 01:06:16 +08:00
parent d8962aba96
commit 978d9a8309
327 changed files with 34070 additions and 5642 deletions

View File

@@ -0,0 +1,31 @@
"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 配置" };
}
}

View File

@@ -0,0 +1,39 @@
"use server";
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
import { Permissions } from "@/shared/types/permissions";
import {
getKnowledgePointsByTextbookId,
getKnowledgePointsByChapterId,
} from "@/modules/textbooks/data-access";
import type { ActionState } from "./types";
// 加载知识点选项(供客户端知识点选择器使用)
export async function getKnowledgePointOptionsAction(input: {
textbookId?: string;
chapterId?: string;
}): Promise<
ActionState<{ options: { id: string; name: string }[] }>
> {
try {
await requirePermission(Permissions.LESSON_PLAN_READ);
if (!input.textbookId) return { success: true, data: { options: [] } };
let kps;
if (input.chapterId) {
kps = await getKnowledgePointsByChapterId(input.chapterId);
} else {
kps = await getKnowledgePointsByTextbookId(input.textbookId);
}
return {
success: true,
data: {
options: kps.map((kp) => ({ id: kp.id, name: kp.name })),
},
};
} catch (e) {
if (e instanceof PermissionDeniedError)
return { success: false, message: e.message };
return { success: false, message: "加载知识点失败" };
}
}

View File

@@ -0,0 +1,51 @@
"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 {
const ctx = await requirePermission(
Permissions.LESSON_PLAN_PUBLISH,
);
await requirePermission(Permissions.HOMEWORK_CREATE);
const result = await publishLessonPlanHomework({
planId: input.planId,
blockId: input.blockId,
userId: ctx.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 : "发布失败",
};
}
}

View File

@@ -0,0 +1,284 @@
"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";
// ---- 课案列表 ----
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 requirePermission(Permissions.LESSON_PLAN_READ);
const items = await getLessonPlans(params, ctx.dataScope, ctx.userId);
return { success: true, data: { items } };
} catch (e) {
if (e instanceof PermissionDeniedError)
return { success: false, message: e.message };
return { success: false, message: "获取课案列表失败" };
}
}
// ---- 单课案 ----
export async function getLessonPlanByIdAction(
planId: string,
): Promise<
ActionState<{ plan: Awaited<ReturnType<typeof getLessonPlanById>> }>
> {
try {
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
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 requirePermission(Permissions.LESSON_PLAN_READ);
const versions = await getLessonPlanVersions(planId, ctx.userId);
return { success: true, data: { versions } };
} catch (e) {
if (e instanceof PermissionDeniedError)
return { success: false, message: e.message };
return { success: false, message: "获取版本失败" };
}
}
// ---- 回退版本 ----
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 requirePermission(Permissions.LESSON_PLAN_READ);
const templates = await getLessonPlanTemplates(ctx.userId);
return { success: true, data: { templates } };
} catch (e) {
if (e instanceof PermissionDeniedError)
return { success: false, message: e.message };
return { success: false, message: "获取模板失败" };
}
}
// ---- 另存为模板 ----
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: "删除模板失败" };
}
}

View File

@@ -0,0 +1,65 @@
import "server-only";
import { env } from "@/env.mjs";
import { createAiChatCompletion } from "@/shared/lib/ai";
import {
getKnowledgePointsByTextbookId,
getKnowledgePointsByChapterId,
} 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.nodes
.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 = chapterId
? await getKnowledgePointsByChapterId(chapterId)
: await getKnowledgePointsByTextbookId(textbookId);
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 }],
model: env.AI_MODEL ?? "gpt-4o-mini",
temperature: 0.3,
});
try {
// 尝试从返回内容中提取 JSON 数组
const jsonMatch = content.match(/\[[\s\S]*\]/);
if (!jsonMatch) return [];
const parsed = JSON.parse(jsonMatch[0]) 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 [];
}
}

View File

@@ -0,0 +1,181 @@
"use client";
/**
* @deprecated 已被 NodeEditor 替代,保留此文件用于向后兼容。
* 列表式渲染器,使用新的 nodes API。
*/
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 { ExerciseBlock } from "./blocks/exercise-block";
import { TextStudyBlock } from "./blocks/text-study-block";
import { ReflectionBlock } from "./blocks/reflection-block";
import type { LessonPlanNode, RichTextBlockData } from "../types";
interface BlockRendererProps {
textbookId?: string;
chapterId?: string;
classes?: { id: string; name: string }[];
}
function SortableBlock({
node,
index,
total,
textbookId,
chapterId,
classes,
}: {
node: LessonPlanNode;
index: number;
total: number;
textbookId?: string;
chapterId?: string;
classes?: { id: string; name: string }[];
}) {
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: node.id });
const { updateNode, removeNode } = useLessonPlanEditor();
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const isRichText = RICH_TEXT_BLOCK_TYPES.includes(node.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={node.title}
onChange={(e) => updateNode(node.id, { title: e.target.value })}
className="flex-1 bg-transparent font-title-md text-title-md focus:outline-none"
/>
<button
onClick={() => updateNode(node.id, { order: 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={() => updateNode(node.id, { order: 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={() => removeNode(node.id)}
className="p-1 text-error hover:text-error/80"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="p-2">
{isRichText ? (
<RichTextBlock
data={node.data as RichTextBlockData}
textbookId={textbookId}
chapterId={chapterId}
onUpdate={(d) => updateNode(node.id, { data: d })}
/>
) : node.type === "exercise" ? (
<ExerciseBlock
blockId={node.id}
data={node.data as never}
classes={classes ?? []}
/>
) : node.type === "text_study" ? (
<TextStudyBlock
blockId={node.id}
data={node.data as never}
/>
) : node.type === "reflection" ? (
<ReflectionBlock
data={node.data as RichTextBlockData}
onUpdate={(d) => updateNode(node.id, { data: d })}
/>
) : (
<div className="text-on-surface-variant text-sm p-4">
block
</div>
)}
</div>
</div>
);
}
export function BlockRenderer({
textbookId,
chapterId,
classes,
}: BlockRendererProps) {
const { doc } = useLessonPlanEditor();
function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over || active.id === over.id) return;
// 拖拽排序仅更新 order 字段,实际位置由节点图管理
const oldIndex = doc.nodes.findIndex((b) => b.id === active.id);
const newIndex = doc.nodes.findIndex((b) => b.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
// 交换 order
const nodes = [...doc.nodes];
const tmpOrder = nodes[oldIndex].order;
nodes[oldIndex].order = nodes[newIndex].order;
nodes[newIndex].order = tmpOrder;
}
return (
<DndContext collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext
items={doc.nodes.map((b) => b.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-4">
{doc.nodes.map((b, i) => (
<SortableBlock
key={b.id}
node={b}
index={i}
total={doc.nodes.length}
textbookId={textbookId}
chapterId={chapterId}
classes={classes}
/>
))}
</div>
</SortableContext>
</DndContext>
);
}

View File

@@ -0,0 +1,155 @@
"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 { PublishHomeworkDialog } from "../publish-homework-dialog";
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;
classes: { id: string; name: string }[];
}
export function ExerciseBlock({ blockId, data, classes }: Props) {
const { updateNode, planId } = useLessonPlanEditor();
const [showBank, setShowBank] = useState(false);
const [showInline, setShowInline] = useState(false);
const [showPublish, setShowPublish] = useState(false);
function update(patch: Partial<ExerciseBlockData>) {
updateNode(blockId, { data: { ...data, ...patch } });
}
function addItems(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 flex-wrap">
<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>
{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>
)
)}
</div>
{showBank && (
<QuestionBankPicker
existingIds={data.items.map((i) => i.questionId)}
onPick={addItems}
onClose={() => setShowBank(false)}
/>
)}
{showInline && (
<InlineQuestionEditor
onAdd={(item) => {
addItems([item]);
setShowInline(false);
}}
onClose={() => setShowInline(false)}
/>
)}
{showPublish && (
<PublishHomeworkDialog
planId={planId}
blockId={blockId}
classes={classes}
onClose={() => setShowPublish(false)}
onPublished={() => window.location.reload()}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,14 @@
"use client";
import { RichTextBlock } from "./rich-text-block";
import type { RichTextBlockData } from "../../types";
interface Props {
data: RichTextBlockData;
onUpdate: (data: RichTextBlockData) => void;
}
export function ReflectionBlock(props: Props) {
// 教学反思在 P1 阶段与普通富文本一致P3 再扩展学情数据嵌入
return <RichTextBlock {...props} hint="课后填写教学反思..." />;
}

View File

@@ -0,0 +1,81 @@
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import { useEffect, useState } from "react";
import type { RichTextBlockData } from "../../types";
import { KnowledgePointPicker } from "../knowledge-point-picker";
import { Tag } from "lucide-react";
interface Props {
data: RichTextBlockData;
hint?: string;
textbookId?: string;
chapterId?: string;
onUpdate: (data: RichTextBlockData) => void;
}
export function RichTextBlock({
data,
hint,
textbookId,
chapterId,
onUpdate,
}: Props) {
const editor = useEditor({
extensions: [
StarterKit,
Placeholder.configure({ placeholder: hint ?? "输入内容..." }),
],
content: data.html,
immediatelyRender: false,
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);
}
}, [data.html, editor]);
const [showKpPicker, setShowKpPicker] = useState(false);
return (
<div>
<EditorContent editor={editor} />
<div className="flex items-center gap-2 mt-2 px-3 flex-wrap">
{data.knowledgePointIds.length > 0 && (
<span className="text-xs text-on-surface-variant">
{data.knowledgePointIds.length}
</span>
)}
<button
onClick={() => setShowKpPicker(true)}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<Tag className="w-3 h-3" />
</button>
</div>
{showKpPicker && (
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={data.knowledgePointIds}
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
onClose={() => setShowKpPicker(false)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,128 @@
"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 { updateNode } = useLessonPlanEditor();
const [selection, setSelection] = useState<{
start: number;
end: number;
} | null>(null);
function update(patch: Partial<TextStudyBlockData>) {
updateNode(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>
);
}

View File

@@ -0,0 +1,179 @@
"use client";
import { useState } from "react";
import { createId } from "@paralleldrive/cuid2";
import { Button } from "@/shared/components/ui/button";
import { X } from "lucide-react";
import type { ExerciseItem, InlineQuestionContent } from "../types";
interface Props {
onAdd: (item: ExerciseItem) => void;
onClose: () => void;
}
export function InlineQuestionEditor({ onAdd, onClose }: Props) {
const [type, setType] = useState<
"single_choice" | "text" | "judgment"
>("single_choice");
const [difficulty, setDifficulty] = useState(3);
const [text, setText] = useState("");
const [options, setOptions] = useState<string[]>(["", ""]);
const [correctIdx, setCorrectIdx] = useState(0);
const kpIds: 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,
})),
}
: type === "judgment"
? { text, correctAnswer: correctIdx === 0 }
: { 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 as never)}
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>
)}
{type === "judgment" && (
<div>
<label className="text-sm font-medium"></label>
<div className="flex gap-3 mt-1">
<label className="flex items-center gap-1">
<input
type="radio"
checked={correctIdx === 0}
onChange={() => setCorrectIdx(0)}
/>
</label>
<label className="flex items-center gap-1">
<input
type="radio"
checked={correctIdx === 1}
onChange={() => setCorrectIdx(1)}
/>
</label>
</div>
</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>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/shared/components/ui/button";
import { X } from "lucide-react";
import { getKnowledgePointOptionsAction } from "../actions-kp";
interface KpOption {
id: string;
name: string;
}
interface Props {
textbookId?: string;
chapterId?: string;
selectedIds: string[];
onChange: (ids: string[]) => void;
onClose: () => void;
}
export function KnowledgePointPicker({
textbookId,
chapterId,
selectedIds,
onChange,
onClose,
}: Props) {
const [options, setOptions] = useState<KpOption[]>([]);
const [local, setLocal] = useState<string[]>(selectedIds);
useEffect(() => {
if (!textbookId) {
return;
}
getKnowledgePointOptionsAction({ textbookId, chapterId }).then((res) => {
if (res.success && res.data) setOptions(res.data.options);
});
}, [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">
{options.length === 0 ? (
<p className="text-on-surface-variant text-sm">
</p>
) : (
<div className="space-y-1">
{options.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>
);
}

View File

@@ -0,0 +1,58 @@
"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>
);
}

View File

@@ -0,0 +1,234 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { NodeEditor } from "./node-editor";
import { NodeEditPanel } from "./node-edit-panel";
import { VersionHistoryDrawer } from "./version-history-drawer";
import {
updateLessonPlanAction,
saveLessonPlanVersionAction,
getLessonPlanByIdAction,
} from "../actions";
import { BLOCK_TYPE_LABELS } from "../constants";
import type { BlockType } from "../types";
import { Button } from "@/shared/components/ui/button";
import { Plus, Save, History } from "lucide-react";
interface Props {
planId: string;
initialTitle: string;
initialDoc: import("../types").LessonPlanDocument;
textbookId?: string;
chapterId?: string;
classes?: { id: string; name: string }[];
}
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,
textbookId,
chapterId,
classes,
}: Props) {
const editor = useLessonPlanEditor();
const [showVersions, setShowVersions] = useState(false);
const [showAddMenu, setShowAddMenu] = useState(false);
const [panelOpen, setPanelOpen] = useState(false);
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const versionTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const addMenuRef = useRef<HTMLDivElement>(null);
// 初始化:仅在 planId 变化时 hydrate修复 P1-3
const initKey = planId;
useEffect(() => {
useLessonPlanEditor.getState().hydrate(planId, initialTitle, initialDoc);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initKey]);
// 选中节点时打开侧边面板
useEffect(() => {
if (editor.selectedNodeId) setPanelOpen(true);
}, [editor.selectedNodeId]);
// 自动保存debounce 3s- 用 getState() 获取最新值(修复 P1-4
useEffect(() => {
if (!editor.isDirty) return;
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
autoSaveTimer.current = setTimeout(async () => {
const state = useLessonPlanEditor.getState();
state.setSaving(true);
const res = await updateLessonPlanAction({
planId: state.planId,
title: state.title,
content: state.doc,
});
state.setSaving(false);
if (res.success) state.markSaved();
}, 3000);
return () => {
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
};
}, [editor.isDirty, editor.doc, planId]);
// 定时自动版本30min
useEffect(() => {
versionTimer.current = setInterval(async () => {
const state = useLessonPlanEditor.getState();
if (!state.isDirty) return;
await saveLessonPlanVersionAction({
planId: state.planId,
content: state.doc,
label: "自动版本",
});
}, 30 * 60 * 1000);
return () => {
if (versionTimer.current) clearInterval(versionTimer.current);
};
}, [planId]);
// 离开未保存提示P3-1
useEffect(() => {
function handleBeforeUnload(e: BeforeUnloadEvent) {
if (useLessonPlanEditor.getState().isDirty) {
e.preventDefault();
e.returnValue = "";
}
}
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, []);
// 添加节点菜单点击外部关闭P3-2
useEffect(() => {
if (!showAddMenu) return;
function handleClickOutside(e: MouseEvent) {
if (addMenuRef.current && !addMenuRef.current.contains(e.target as Node)) {
setShowAddMenu(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showAddMenu]);
const handleManualSave = useCallback(async () => {
const state = useLessonPlanEditor.getState();
state.setSaving(true);
const res = await saveLessonPlanVersionAction({
planId: state.planId,
content: state.doc,
});
state.setSaving(false);
if (res.success) state.markSaved();
}, []);
// 版本回退后刷新内容(修复 P1-1
const handleReverted = useCallback(async () => {
const state = useLessonPlanEditor.getState();
const res = await getLessonPlanByIdAction(state.planId);
if (res.success && res.data?.plan) {
state.hydrate(state.planId, res.data.plan.title, res.data.plan.content);
}
}, []);
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>
{/* 主区域:画布 + 侧边面板 */}
<div className="flex-1 flex overflow-hidden">
{/* 节点画布 */}
<div className="flex-1 relative">
<NodeEditor
textbookId={textbookId}
chapterId={chapterId}
classes={classes}
/>
{/* 添加节点浮动按钮 */}
<div className="absolute bottom-4 left-4 z-10" ref={addMenuRef}>
<Button
variant="default"
onClick={() => setShowAddMenu(!showAddMenu)}
>
<Plus className="w-4 h-4 mr-1" />
</Button>
{showAddMenu && (
<div className="absolute bottom-12 left-0 bg-surface border border-outline-variant rounded-lg shadow-lg p-2 grid grid-cols-2 gap-1 w-72 max-h-[60vh] overflow-y-auto">
{BLOCK_TYPES_TO_ADD.map((t) => (
<button
key={t}
onClick={() => {
editor.addNode(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>
{/* 侧边内容编辑面板 */}
{panelOpen && editor.selectedNodeId && (
<div className="w-[420px] flex-shrink-0">
<NodeEditPanel
textbookId={textbookId}
chapterId={chapterId}
classes={classes}
/>
</div>
)}
</div>
<VersionHistoryDrawer
open={showVersions}
onClose={() => setShowVersions(false)}
planId={planId}
onReverted={handleReverted}
/>
</div>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { useEffect, useState } from "react";
import { useDebounce } from "@/shared/hooks/use-debounce";
interface Props {
onFilter: (params: {
query?: string;
subjectId?: string;
status?: string;
}) => void;
subjects: { id: string; name: string }[];
}
export function LessonPlanFilters({ onFilter, subjects }: Props) {
const [query, setQuery] = useState("");
const [subjectId, setSubjectId] = useState<string>("");
const [status, setStatus] = useState<string>("");
// 修复 P1-6搜索 debounce 300ms
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
onFilter({
query: debouncedQuery || undefined,
subjectId: subjectId || undefined,
status: status || undefined,
});
}, [debouncedQuery, subjectId, status, onFilter]);
return (
<div className="flex gap-2 flex-wrap">
<input
placeholder="搜索标题..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
/>
<select
value={subjectId}
onChange={(e) => setSubjectId(e.target.value)}
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
value={status}
onChange={(e) => setStatus(e.target.value)}
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>
);
}

View File

@@ -0,0 +1,42 @@
"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">
&ldquo;&rdquo;
</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>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { RICH_TEXT_BLOCK_TYPES } from "../constants";
import { RichTextBlock } from "./blocks/rich-text-block";
import { ExerciseBlock } from "./blocks/exercise-block";
import { TextStudyBlock } from "./blocks/text-study-block";
import { ReflectionBlock } from "./blocks/reflection-block";
import { Button } from "@/shared/components/ui/button";
import { Trash2, X } from "lucide-react";
import type {
ExerciseBlockData,
RichTextBlockData,
TextStudyBlockData,
} from "../types";
interface Props {
textbookId?: string;
chapterId?: string;
classes?: { id: string; name: string }[];
}
export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
const { doc, selectedNodeId, updateNode, removeNode, selectNode } =
useLessonPlanEditor();
const node = doc.nodes.find((n) => n.id === selectedNodeId);
if (!node) {
return (
<div className="h-full flex items-center justify-center text-on-surface-variant text-sm p-4">
线
</div>
);
}
const isRichText = RICH_TEXT_BLOCK_TYPES.includes(node.type);
return (
<div className="h-full flex flex-col border-l border-outline-variant bg-surface">
{/* 面板头部 */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant">
<input
value={node.title}
onChange={(e) => updateNode(node.id, { title: e.target.value })}
className="flex-1 bg-transparent font-title-md text-title-md focus:outline-none"
/>
<Button
variant="ghost"
size="sm"
onClick={() => selectNode(null)}
>
<X className="w-4 h-4" />
</Button>
</div>
{/* 内容编辑区 */}
<div className="flex-1 overflow-y-auto p-3">
{isRichText ? (
<RichTextBlock
data={node.data as RichTextBlockData}
textbookId={textbookId}
chapterId={chapterId}
onUpdate={(d) => updateNode(node.id, { data: d })}
/>
) : node.type === "exercise" ? (
<ExerciseBlock
blockId={node.id}
data={node.data as ExerciseBlockData}
classes={classes ?? []}
/>
) : node.type === "text_study" ? (
<TextStudyBlock
blockId={node.id}
data={node.data as TextStudyBlockData}
/>
) : node.type === "reflection" ? (
<ReflectionBlock
data={node.data as RichTextBlockData}
onUpdate={(d) => updateNode(node.id, { data: d })}
/>
) : (
<div className="text-on-surface-variant text-sm p-4">
</div>
)}
</div>
{/* 底部操作 */}
<div className="px-4 py-2 border-t border-outline-variant">
<Button
variant="outline"
size="sm"
className="text-error"
onClick={() => removeNode(node.id)}
>
<Trash2 className="w-3 h-3 mr-1" />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,155 @@
"use client";
import { useCallback, useMemo } from "react";
import {
ReactFlow,
Background,
Controls,
MiniMap,
type Node,
type Edge,
type NodeChange,
type EdgeChange,
type Connection,
applyNodeChanges,
applyEdgeChanges,
BackgroundVariant,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { LessonNode } from "./nodes/lesson-node";
import type { LessonPlanNode } from "../types";
const nodeTypes = { lesson: LessonNode };
interface Props {
textbookId?: string;
chapterId?: string;
classes?: { id: string; name: string }[];
}
export function NodeEditor({ textbookId, chapterId, classes }: Props) {
const { doc, selectedNodeId, updateNodePosition, removeNode, connect, selectNode, setEdges } =
useLessonPlanEditor();
// 我们的 nodes → React Flow nodes
const rfNodes: Node[] = useMemo(
() =>
doc.nodes.map((n) => ({
id: n.id,
type: "lesson",
position: n.position,
data: n as unknown as Record<string, unknown>,
selected: n.id === selectedNodeId,
})),
[doc.nodes, selectedNodeId],
);
// edges 直接兼容
const rfEdges: Edge[] = useMemo(
() =>
doc.edges.map((e) => ({
...e,
animated: true,
style: { stroke: "#1976d2", strokeWidth: 2 },
})),
[doc.edges],
);
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
changes.forEach((change) => {
if (change.type === "position" && change.position) {
updateNodePosition(change.id, change.position);
} else if (change.type === "remove") {
removeNode(change.id);
} else if (change.type === "select") {
selectNode(change.selected ? change.id : null);
}
});
// applyNodeChanges 用于内部状态同步,但我们用 zustand 管理,这里不需要
void applyNodeChanges;
},
[updateNodePosition, removeNode, selectNode],
);
const onConnect = useCallback(
(conn: Connection) => {
if (conn.source && conn.target) {
connect(conn.source, conn.target);
}
},
[connect],
);
// 同步 edges 变化(如拖拽重连)
const onEdgesChangeSync = useCallback(
(changes: EdgeChange[]) => {
// 简单处理:删除时调用 disconnect
const nextEdges = applyEdgeChanges(changes, rfEdges);
const ourEdges = nextEdges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle ?? null,
targetHandle: e.targetHandle ?? null,
}));
setEdges(ourEdges);
},
[rfEdges, setEdges],
);
return (
<div className="w-full h-full">
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChangeSync}
onConnect={onConnect}
onNodeClick={(_, node) => selectNode(node.id)}
onPaneClick={() => selectNode(null)}
fitView
fitViewOptions={{ padding: 0.2, maxZoom: 1.2 }}
defaultEdgeOptions={{
animated: true,
style: { stroke: "#1976d2", strokeWidth: 2 },
}}
proOptions={{ hideAttribution: true }}
className="bg-surface-container-low"
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="#ccc"
/>
<Controls className="!bg-surface !border-outline-variant" />
<MiniMap
className="!bg-surface !border-outline-variant"
nodeColor={(n) => {
const data = n.data as unknown as LessonPlanNode;
const colors: Record<string, string> = {
objective: "#4caf50",
key_point: "#f44336",
import: "#2196f3",
new_teaching: "#9c27b0",
consolidation: "#ff9800",
summary: "#607d8b",
homework: "#795548",
blackboard: "#009688",
text_study: "#3f51b5",
exercise: "#e91e63",
rich_text: "#9e9e9e",
reflection: "#cddc39",
};
return colors[data.type] ?? "#9e9e9e";
}}
/>
</ReactFlow>
{/* 隐藏的 props 传递,避免 unused 警告 */}
<span className="hidden" data-textbook={textbookId} data-chapter={chapterId} data-classes={classes?.length} />
</div>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import { memo } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { BLOCK_TYPE_LABELS } from "../../constants";
import type { LessonPlanNode } from "../../types";
// 节点类型 → 图标颜色Material Design 色板)
const NODE_COLORS: Record<string, string> = {
objective: "#4caf50",
key_point: "#f44336",
import: "#2196f3",
new_teaching: "#9c27b0",
consolidation: "#ff9800",
summary: "#607d8b",
homework: "#795548",
blackboard: "#009688",
text_study: "#3f51b5",
exercise: "#e91e63",
rich_text: "#9e9e9e",
reflection: "#cddc39",
};
function getNodeSummary(node: LessonPlanNode): string {
const data = node.data as {
html?: string;
sourceText?: string;
items?: unknown[];
knowledgePointIds?: string[];
};
if (data.items !== undefined) {
return `${data.items.length} 道题`;
}
if (data.sourceText !== undefined && data.sourceText) {
return `${data.sourceText.length}`;
}
if (data.html) {
// 去标签后取前 40 字
const text = data.html.replace(/<[^>]+>/g, "").trim();
return text.slice(0, 40) || "空";
}
return "空";
}
export const LessonNode = memo(function LessonNode({
data,
selected,
}: NodeProps) {
const nodeData = data as unknown as LessonPlanNode;
const color = NODE_COLORS[nodeData.type] ?? "#9e9e9e";
return (
<div
className="rounded-lg border-2 bg-surface shadow-md min-w-[200px] max-w-[260px] transition-shadow"
style={{
borderColor: selected ? "#1976d2" : color,
boxShadow: selected ? "0 0 0 2px rgba(25,118,210,0.3)" : undefined,
}}
>
<Handle
type="target"
position={Position.Top}
className="!bg-on-surface !w-3 !h-3 !border-2 !border-surface"
/>
<div
className="px-3 py-2 rounded-t-md text-white text-xs font-medium flex items-center gap-1"
style={{ backgroundColor: color }}
>
<span>{BLOCK_TYPE_LABELS[nodeData.type] ?? nodeData.type}</span>
</div>
<div className="px-3 py-2">
<div className="text-sm font-medium text-on-surface truncate">
{nodeData.title}
</div>
<div className="text-xs text-on-surface-variant mt-1">
{getNodeSummary(nodeData)}
</div>
</div>
<Handle
type="source"
position={Position.Bottom}
className="!bg-on-surface !w-3 !h-3 !border-2 !border-surface"
/>
</div>
);
});

View File

@@ -0,0 +1,121 @@
"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>
);
}

View File

@@ -0,0 +1,143 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { getQuestionsAction } from "@/modules/questions/actions"
import { Button } from "@/shared/components/ui/button"
import { useDebounce } from "@/shared/hooks/use-debounce"
import { X } from "lucide-react"
import { QuestionBankFilters } from "@/shared/components/question/question-bank-filters"
import type { ExerciseItem } from "../types"
import type { QuestionType } from "@/modules/questions/types"
interface QuestionRow {
id: string
type: string
difficulty: number
content: unknown
}
interface Props {
onPick: (items: ExerciseItem[]) => void
onClose: () => void
existingIds: string[]
}
export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
const [questions, setQuestions] = useState<QuestionRow[]>([])
const [picked, setPicked] = useState<ExerciseItem[]>([])
// QuestionBankFilters 使用字符串值,这里转换为 filters 对象
const [searchValue, setSearchValue] = useState("")
const [typeValue, setTypeValue] = useState<string>("all")
const [difficultyValue, setDifficultyValue] = useState<string>("all")
const filters = useMemo<{
q?: string
type?: QuestionType
difficulty?: number
}>(() => {
const newFilters: {
q?: string
type?: QuestionType
difficulty?: number
} = {}
if (searchValue) newFilters.q = searchValue
if (typeValue !== "all") newFilters.type = typeValue as QuestionType
if (difficultyValue !== "all") newFilters.difficulty = Number(difficultyValue)
return newFilters
}, [searchValue, typeValue, difficultyValue])
// 修复 P1-5搜索 debounce 300ms
const debouncedFilters = useDebounce(filters, 300)
useEffect(() => {
getQuestionsAction(debouncedFilters).then((res) => {
if (res.success && res.data) {
const data = res.data.data
setQuestions(
data.map((q) => ({
id: q.id,
type: q.type,
difficulty: q.difficulty,
content: q.content,
})),
)
}
})
}, [debouncedFilters])
function add(q: QuestionRow) {
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,
},
])
}
function previewText(content: unknown): string {
if (typeof content === "string") return content.slice(0, 80)
try {
return JSON.stringify(content).slice(0, 80)
} catch {
return ""
}
}
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="p-4 border-b">
<QuestionBankFilters
search={searchValue}
onSearchChange={setSearchValue}
type={typeValue}
onTypeChange={setTypeValue}
difficulty={difficultyValue}
onDifficultyChange={setDifficultyValue}
layout="default"
/>
</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 mr-2">{previewText(q.content)}</span>
<span className="text-xs text-on-surface-variant mr-2">
{q.type} · {q.difficulty}
</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>
)
}

View File

@@ -0,0 +1,69 @@
"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>
);
}

View File

@@ -0,0 +1,102 @@
"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;
let cancelled = false;
// 用微任务延迟避免同步 setState 触发级联渲染
queueMicrotask(() => {
if (cancelled) return;
setLoading(true);
getLessonPlanVersionsAction(planId).then((res) => {
if (cancelled) return;
if (res.success && res.data) setVersions(res.data.versions);
setLoading(false);
});
});
return () => {
cancelled = true;
};
}, [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>
);
}

View File

@@ -0,0 +1,107 @@
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便于幂等
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: "已归档",
};

View File

@@ -0,0 +1,44 @@
import "server-only";
import { like } from "drizzle-orm";
import { db } from "@/shared/db";
import { lessonPlans } from "@/shared/db/schema";
import { normalizeDocument } from "./data-access";
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 = normalizeDocument(r.content);
return doc.nodes.some((b) => {
const data = b.data as { knowledgePointIds?: string[] };
return 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 = normalizeDocument(r.content);
return doc.nodes.some((b) => {
if (b.type !== "exercise") return false;
const data = b.data as { items?: Array<{ questionId: string }> };
return data?.items?.some((it) => it.questionId === questionId);
});
}) as unknown as LessonPlanListItem[];
}

View File

@@ -0,0 +1,92 @@
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 { normalizeDocument } from "./data-access";
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 = normalizeDocument(plan[0].content);
const skeleton: TemplateBlockSkeleton[] = doc.nodes.map((b) => ({
type: b.type,
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),
),
);
}

View File

@@ -0,0 +1,143 @@
import "server-only";
import { and, desc, eq, inArray, max } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
import { db } from "@/shared/db";
import { lessonPlanVersions, lessonPlans } from "@/shared/db/schema";
import { normalizeDocument } from "./data-access";
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 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 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 normalizeDocument(rows[0].content);
}
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),
),
),
);
}

View File

@@ -0,0 +1,318 @@
import "server-only";
import { cache } from "react";
import { and, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
import { db } from "@/shared/db";
import {
lessonPlans,
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,
LessonPlanDocument,
LessonPlanDocumentV1,
LessonPlanEdge,
LessonPlanListItem,
LessonPlanNode,
LessonPlanTemplate,
TemplateBlockSkeleton,
} from "./types";
// ---- v1 → v2 迁移:将旧 blocks 数组转换为 nodes + 线性 edges ----
export function migrateV1ToV2(doc: LessonPlanDocumentV1): LessonPlanDocument {
const nodes: LessonPlanNode[] = doc.blocks.map((b, i) => ({
...b,
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
}));
const edges: LessonPlanEdge[] = [];
for (let i = 0; i < nodes.length - 1; i++) {
edges.push({
id: `e_${nodes[i].id}_${nodes[i + 1].id}`,
source: nodes[i].id,
target: nodes[i + 1].id,
});
}
return { version: 2, nodes, edges };
}
// ---- 规范化:确保 content 是 v2 格式(兼容旧数据)----
export function normalizeDocument(
content: unknown,
): LessonPlanDocument {
if (content && typeof content === "object") {
const c = content as { version?: number };
if (c.version === 2) {
return content as LessonPlanDocument;
}
if (c.version === 1) {
return migrateV1ToV2(content as LessonPlanDocumentV1);
}
}
// 空文档
return { version: 2, nodes: [], edges: [] };
}
// ---- 模板初始化:根据骨架生成初始 contentv2----
export function buildInitialContent(
blocks: TemplateBlockSkeleton[],
): LessonPlanDocument {
const nodes: LessonPlanNode[] = 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,
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
}));
const edges: LessonPlanEdge[] = [];
for (let i = 0; i < nodes.length - 1; i++) {
edges.push({
id: `e_${nodes[i].id}_${nodes[i + 1].id}`,
source: nodes[i].id,
target: nodes[i + 1].id,
});
}
return { version: 2, nodes, edges };
}
// ---- 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[] = [
sql`${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));
const items = rows as unknown as LessonPlanListItem[];
items.forEach((it) => {
it.content = normalizeDocument(it.content);
});
return items;
},
);
// ---- 单课案 ----
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;
const plan = row as unknown as LessonPlan;
plan.content = normalizeDocument(plan.content);
return plan;
},
);
// ---- 创建 ----
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 };
}
// ---- 模板查询(内部)----
export async function getTemplateById(
templateId: string,
): Promise<LessonPlanTemplate | null> {
// 先查 system 固定模板
const sysDef = SYSTEM_TEMPLATES.find((t) => t.id === templateId);
if (sysDef) {
return {
id: sysDef.id,
name: sysDef.name,
type: "system",
scope: sysDef.scope,
blocks: sysDef.blocks,
creatorId: null,
createdAt: "",
updatedAt: "",
};
}
// 再查 DBpersonal 模板)
const rows = await db
.select()
.from(lessonPlanTemplates)
.where(eq(lessonPlanTemplates.id, templateId))
.limit(1);
return rows.length > 0
? (rows[0] as unknown as LessonPlanTemplate)
: null;
}

View File

@@ -0,0 +1,170 @@
"use client";
import { create } from "zustand";
import { createId } from "@paralleldrive/cuid2";
import type {
Block,
BlockType,
LessonPlanDocument,
LessonPlanEdge,
LessonPlanNode,
} from "../types";
import { BLOCK_TYPE_LABELS } from "../constants";
interface EditorState {
planId: string;
title: string;
doc: LessonPlanDocument;
isDirty: boolean;
isSaving: boolean;
lastSavedAt: number | null;
selectedNodeId: string | null;
setTitle: (title: string) => void;
setPlanId: (planId: string) => void;
hydrate: (planId: string, title: string, doc: LessonPlanDocument) => void;
addNode: (type: BlockType, position?: { x: number; y: number }) => string;
updateNode: (id: string, patch: Partial<Block>) => void;
updateNodePosition: (id: string, position: { x: number; y: number }) => void;
removeNode: (id: string) => void;
connect: (source: string, target: string) => void;
disconnect: (edgeId: string) => void;
setEdges: (edges: LessonPlanEdge[]) => void;
selectNode: (id: string | null) => void;
markSaved: () => void;
setSaving: (saving: boolean) => void;
replaceDoc: (doc: LessonPlanDocument) => void;
}
function reindex(nodes: LessonPlanNode[]): LessonPlanNode[] {
return nodes.map((n, i) => ({ ...n, order: i }));
}
function defaultData(type: BlockType): Block["data"] {
return type === "exercise"
? { items: [], purpose: "class_practice", knowledgePointIds: [] }
: type === "text_study"
? { sourceText: "", annotations: [], knowledgePointIds: [] }
: { html: "", knowledgePointIds: [] };
}
export const useLessonPlanEditor = create<EditorState>((set, get) => ({
planId: "",
title: "",
doc: { version: 2, nodes: [], edges: [] },
isDirty: false,
isSaving: false,
lastSavedAt: null,
selectedNodeId: null,
setTitle: (title) => set({ title, isDirty: true }),
setPlanId: (planId) => set({ planId }),
// 仅在 planId 变化时调用,避免覆盖用户编辑内容(修复 P1-3
hydrate: (planId, title, doc) =>
set({
planId,
title,
doc,
isDirty: false,
lastSavedAt: Date.now(),
selectedNodeId: null,
}),
addNode: (type, position) => {
const id = createId();
const nodeCount = get().doc.nodes.length;
const node: LessonPlanNode = {
id,
type,
title: BLOCK_TYPE_LABELS[type],
data: defaultData(type),
order: nodeCount,
position: position ?? {
x: 80 + (nodeCount % 4) * 280,
y: 80 + Math.floor(nodeCount / 4) * 200,
},
};
set((s) => ({
doc: { ...s.doc, nodes: [...s.doc.nodes, node] },
isDirty: true,
selectedNodeId: id,
}));
return id;
},
updateNode: (id, patch) =>
set((s) => ({
doc: {
...s.doc,
nodes: s.doc.nodes.map((n) =>
n.id === id ? { ...n, ...patch } : n,
),
},
isDirty: true,
})),
updateNodePosition: (id, position) =>
set((s) => ({
doc: {
...s.doc,
nodes: s.doc.nodes.map((n) =>
n.id === id ? { ...n, position } : n,
),
},
isDirty: true,
})),
removeNode: (id) =>
set((s) => ({
doc: {
...s.doc,
nodes: reindex(s.doc.nodes.filter((n) => n.id !== id)),
edges: s.doc.edges.filter(
(e) => e.source !== id && e.target !== id,
),
},
isDirty: true,
selectedNodeId:
s.selectedNodeId === id ? null : s.selectedNodeId,
})),
connect: (source, target) =>
set((s) => {
// 避免重复连线
if (
s.doc.edges.some(
(e) => e.source === source && e.target === target,
)
)
return s;
const edge: LessonPlanEdge = {
id: `e_${source}_${target}_${createId().slice(0, 6)}`,
source,
target,
};
return { doc: { ...s.doc, edges: [...s.doc.edges, edge] }, isDirty: true };
}),
disconnect: (edgeId) =>
set((s) => ({
doc: {
...s.doc,
edges: s.doc.edges.filter((e) => e.id !== edgeId),
},
isDirty: true,
})),
setEdges: (edges) => set((s) => ({ doc: { ...s.doc, edges }, isDirty: true })),
selectNode: (id) => set({ selectedNodeId: id }),
markSaved: () => set({ isDirty: false, lastSavedAt: Date.now() }),
setSaving: (saving) => set({ isSaving: saving }),
replaceDoc: (doc) => set({ doc, isDirty: false }),
}));

View File

@@ -0,0 +1,175 @@
import "server-only";
import { eq } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
import { db } from "@/shared/db";
import {
lessonPlans,
examQuestions,
} 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 { normalizeDocument } from "./data-access";
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;
}
// 查询班级学生列表(避免直接依赖 classes 模块的内部表)
async function getStudentIdsByClassIds(
classIds: string[],
): Promise<string[]> {
if (classIds.length === 0) return [];
const { inArray } = await import("drizzle-orm");
const { classEnrollments } = await import("@/shared/db/schema");
const rows = await db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(inArray(classEnrollments.classId, classIds));
return rows.map((r) => r.studentId);
}
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 row = rows[0] as unknown as {
id: string;
content: unknown;
creatorId: string;
title: string;
textbookId: string | null;
chapterId: string | null;
subjectId: string | null;
gradeId: string | null;
};
const plan = {
...row,
content: normalizeDocument(row.content),
};
if (plan.creatorId !== input.userId)
throw new Error("无权发布");
// 2. 定位 exercise block
const block = plan.content.nodes.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.nodes.find((b) => b.id === input.blockId);
if (!newBlock || newBlock.type !== "exercise")
throw new Error("练习块不存在");
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,
knowledgePointIds: item.inlineContent.knowledgePointIds,
},
input.userId,
);
newData.items[i] = {
...item,
questionId,
inlineContent: undefined,
};
}
}
// 4. 打包 exam 草稿
const examId = createId();
if (!plan.subjectId || !plan.gradeId) {
throw new Error("课案缺少学科或年级信息,无法发布");
}
await persistExamDraft({
examId,
title: `${plan.title} - 作业`,
creatorId: input.userId,
subjectId: plan.subjectId,
gradeId: plan.gradeId,
scheduledAt: undefined,
description: `来自课案:${plan.title}`,
});
// 插入 examQuestions
if (newData.items.length > 0) {
await db.insert(examQuestions).values(
newData.items.map((it, i) => ({
examId,
questionId: it.questionId,
score: it.score,
order: i,
})),
);
}
// 5. 下发作业
const assignmentId = createId();
const targetStudentIds = await getStudentIdsByClassIds(input.classIds);
if (targetStudentIds.length === 0) {
throw new Error("所选班级无学生");
}
await createHomeworkAssignment({
assignmentId,
sourceExamId: examId,
title: `${plan.title} - 作业`,
description: `来自课案:${plan.title}`,
structure: null,
status: "published",
creatorId: input.userId,
availableAt: input.availableAt ?? null,
dueAt: input.dueAt ?? null,
allowLate: false,
lateDueAt: null,
maxAttempts: 1,
publish: true,
questions: newData.items.map((it, i) => ({
questionId: it.questionId,
score: it.score,
order: i,
})),
targetStudentIds,
});
// 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 };
}

View File

@@ -0,0 +1,34 @@
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>;

View File

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

View File

@@ -0,0 +1,177 @@
// 课案状态
export type LessonPlanStatus = "draft" | "published" | "archived";
// Block 类型枚举
export type BlockType =
| "objective"
| "key_point"
| "import"
| "new_teaching"
| "consolidation"
| "summary"
| "homework"
| "blackboard"
| "text_study"
| "exercise"
| "rich_text"
| "reflection";
// 富文本类 block 的 data
export interface RichTextBlockData {
html: string;
knowledgePointIds: string[];
}
// 文本研习 block 的 data
export interface TextStudyAnnotation {
id: string;
anchor: { start: number; end: number };
nodeType: string;
title: string;
note: string;
color: "yellow" | "green";
}
export interface TextStudyBlockData {
sourceText: string;
annotations: TextStudyAnnotation[];
knowledgePointIds: string[];
}
// 练习 block 的 data
export type ExercisePurpose = "class_practice" | "after_class_homework";
export interface InlineQuestionContent {
content: unknown; // 与 questions.content 对齐
type: string; // 与 questionTypeEnum 对齐
difficulty: number;
knowledgePointIds: string[];
}
export interface ExerciseItem {
questionId: string; // bank=真实IDinline=占位 inline_draft_xxx
source: "bank" | "inline";
score: number;
order: number;
inlineContent?: InlineQuestionContent; // 仅 inline
}
export interface ExerciseBlockData {
items: ExerciseItem[];
purpose: ExercisePurpose;
knowledgePointIds: string[];
publishedAssignmentId?: string;
publishedExamId?: string;
publishedAt?: string;
}
// Block 联合
export interface Block {
id: string;
type: BlockType;
title: string;
data: RichTextBlockData | TextStudyBlockData | ExerciseBlockData;
order: number;
}
// 节点Block + 画布坐标)
export interface LessonPlanNode extends Block {
position: { x: number; y: number };
}
// 连线(节点间数据流/流程顺序)
export interface LessonPlanEdge {
id: string;
source: string; // 源节点 id
target: string; // 目标节点 id
sourceHandle?: string | null;
targetHandle?: string | null;
}
// 文档 v1旧格式向后兼容读取
export interface LessonPlanDocumentV1 {
version: 1;
blocks: Block[];
}
// 文档 v2节点图格式
export interface LessonPlanDocument {
version: 2;
nodes: LessonPlanNode[];
edges: LessonPlanEdge[];
}
// 课案
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;
};