feat(lesson-preparation): add anchor canvas design, new blocks, and textbook content node

- Add anchor injector for canvas-based anchor positioning

- Add new block components: blackboard, homework, import, key-point, new-teaching, objective, summary

- Add textbook content node for React Flow canvas

- Update actions (kp, publish, main), data-access (templates, versions, main)

- Update editor, node-editor, block-renderer, and picker components

- Update schema, types, hooks, and lib utilities (document-migration, node-summary, rf-mappers)
This commit is contained in:
SpecialX
2026-06-23 17:37:19 +08:00
parent 1fcef5c3aa
commit 2197e68069
34 changed files with 3190 additions and 402 deletions

View File

@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Tag } from "lucide-react";
import type { BlackboardBlockData } from "../../types";
import { KnowledgePointPicker } from "../knowledge-point-picker";
interface Props {
data: BlackboardBlockData;
textbookId?: string;
chapterId?: string;
onUpdate: (data: BlackboardBlockData) => void;
}
const LAYOUTS: BlackboardBlockData["layout"][] = ["structure", "mindmap", "text"];
export function BlackboardBlock({ data, textbookId, chapterId, onUpdate }: Props) {
const t = useTranslations("lessonPreparation");
const [showKpPicker, setShowKpPicker] = useState(false);
return (
<div className="space-y-3">
<div className="text-xs text-on-surface-variant">
{t("blackboard.hint")}
</div>
<div>
<label className="text-xs font-medium block mb-1">
{t("blackboard.layoutLabel")}
</label>
<select
value={data.layout}
onChange={(e) =>
onUpdate({
...data,
layout: e.target.value as BlackboardBlockData["layout"],
})
}
className="w-full text-sm border border-outline-variant rounded px-2 py-1 bg-surface"
>
{LAYOUTS.map((l) => (
<option key={l} value={l}>
{t(`blackboard.layout.${l}`)}
</option>
))}
</select>
</div>
<div>
<label className="text-xs font-medium block mb-1">
{t("blackboard.contentLabel")}
</label>
<textarea
value={data.content}
onChange={(e) => onUpdate({ ...data, content: e.target.value })}
className="w-full text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[120px] font-mono"
placeholder={t("blackboard.contentPlaceholder")}
/>
</div>
<div className="flex items-center gap-2 flex-wrap">
{data.knowledgePointIds.length > 0 && (
<span className="text-xs text-on-surface-variant">
{t("knowledgePoint.linked", { count: 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" />
{t("knowledgePoint.annotate")}
</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,92 @@
"use client";
import { useTranslations } from "next-intl";
import { Plus, Trash2 } from "lucide-react";
import type { HomeworkAssignment, HomeworkBlockData } from "../../types";
import { Button } from "@/shared/components/ui/button";
interface Props {
data: HomeworkBlockData;
onUpdate: (data: HomeworkBlockData) => void;
}
const TYPES: HomeworkAssignment["type"][] = ["exercise", "reading", "writing"];
export function HomeworkBlock({ data, onUpdate }: Props) {
const t = useTranslations("lessonPreparation");
function updateItem(index: number, patch: Partial<HomeworkAssignment>) {
const next = data.assignments.map((it, i) =>
i === index ? { ...it, ...patch } : it,
);
onUpdate({ ...data, assignments: next });
}
function addItem() {
onUpdate({
...data,
assignments: [...data.assignments, { type: "exercise", description: "" }],
});
}
function removeItem(index: number) {
onUpdate({
...data,
assignments: data.assignments.filter((_, i) => i !== index),
});
}
return (
<div className="space-y-2">
<div className="text-xs text-on-surface-variant">
{t("homework.hint")}
</div>
{data.assignments.map((item, idx) => (
<div key={idx} className="flex items-start gap-2">
<select
value={item.type}
onChange={(e) =>
updateItem(idx, {
type: e.target.value as HomeworkAssignment["type"],
})
}
className="text-xs border border-outline-variant rounded px-1 py-1 bg-surface"
>
{TYPES.map((tp) => (
<option key={tp} value={tp}>
{t(`homework.type.${tp}`)}
</option>
))}
</select>
<input
type="text"
value={item.refId ?? ""}
onChange={(e) => updateItem(idx, { refId: e.target.value || undefined })}
className="w-32 text-sm border border-outline-variant rounded px-2 py-1"
placeholder={t("homework.refIdPlaceholder")}
/>
<input
type="text"
value={item.description}
onChange={(e) => updateItem(idx, { description: e.target.value })}
className="flex-1 text-sm border border-outline-variant rounded px-2 py-1"
placeholder={t("homework.descriptionPlaceholder")}
/>
<Button
variant="ghost"
size="sm"
className="!p-1 text-error"
onClick={() => removeItem(idx)}
aria-label={t("action.delete")}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={addItem}>
<Plus className="w-3 h-3 mr-1" />
{t("homework.addItem")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,70 @@
"use client";
import { useTranslations } from "next-intl";
import type { ImportBlockData } from "../../types";
interface Props {
data: ImportBlockData;
onUpdate: (data: ImportBlockData) => void;
}
const METHODS: ImportBlockData["method"][] = ["question", "situation", "review", "other"];
export function ImportBlock({ data, onUpdate }: Props) {
const t = useTranslations("lessonPreparation");
return (
<div className="space-y-3">
<div className="text-xs text-on-surface-variant">
{t("import.hint")}
</div>
<div>
<label className="text-xs font-medium block mb-1">
{t("import.methodLabel")}
</label>
<select
value={data.method}
onChange={(e) =>
onUpdate({ ...data, method: e.target.value as ImportBlockData["method"] })
}
className="w-full text-sm border border-outline-variant rounded px-2 py-1 bg-surface"
>
{METHODS.map((m) => (
<option key={m} value={m}>
{t(`import.method.${m}`)}
</option>
))}
</select>
</div>
<div>
<label className="text-xs font-medium block mb-1">
{t("import.promptLabel")}
</label>
<textarea
value={data.prompt}
onChange={(e) => onUpdate({ ...data, prompt: e.target.value })}
className="w-full text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[60px]"
placeholder={t("import.promptPlaceholder")}
/>
</div>
<div>
<label className="text-xs font-medium block mb-1">
{t("import.durationLabel")}
</label>
<input
type="number"
min={1}
max={30}
value={data.durationMin}
onChange={(e) =>
onUpdate({ ...data, durationMin: Number(e.target.value) || 0 })
}
className="w-20 text-sm border border-outline-variant rounded px-2 py-1 bg-surface"
/>
<span className="text-xs text-on-surface-variant ml-2">
{t("import.durationUnit")}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useTranslations } from "next-intl";
import { Plus, Trash2 } from "lucide-react";
import type { KeyPointBlockData, KeyPointItem } from "../../types";
import { Button } from "@/shared/components/ui/button";
interface Props {
data: KeyPointBlockData;
onUpdate: (data: KeyPointBlockData) => void;
}
const TYPES: KeyPointItem["type"][] = ["key", "difficult"];
export function KeyPointBlock({ data, onUpdate }: Props) {
const t = useTranslations("lessonPreparation");
function updateItem(index: number, patch: Partial<KeyPointItem>) {
const next = data.keyPoints.map((it, i) =>
i === index ? { ...it, ...patch } : it,
);
onUpdate({ ...data, keyPoints: next });
}
function addItem() {
onUpdate({
...data,
keyPoints: [...data.keyPoints, { type: "key", text: "" }],
});
}
function removeItem(index: number) {
onUpdate({
...data,
keyPoints: data.keyPoints.filter((_, i) => i !== index),
});
}
return (
<div className="space-y-2">
<div className="text-xs text-on-surface-variant">
{t("keyPoint.hint")}
</div>
{data.keyPoints.map((item, idx) => (
<div key={idx} className="flex items-start gap-2">
<select
value={item.type}
onChange={(e) =>
updateItem(idx, { type: e.target.value as KeyPointItem["type"] })
}
className="text-xs border border-outline-variant rounded px-1 py-1 bg-surface"
>
{TYPES.map((tp) => (
<option key={tp} value={tp}>
{t(`keyPoint.type.${tp}`)}
</option>
))}
</select>
<textarea
value={item.text}
onChange={(e) => updateItem(idx, { text: e.target.value })}
className="flex-1 text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[40px]"
placeholder={t("keyPoint.textPlaceholder")}
/>
<Button
variant="ghost"
size="sm"
className="!p-1 text-error"
onClick={() => removeItem(idx)}
aria-label={t("action.delete")}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={addItem}>
<Plus className="w-3 h-3 mr-1" />
{t("keyPoint.addItem")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Plus, Trash2, Tag } from "lucide-react";
import type { NewTeachingBlockData, NewTeachingPoint } from "../../types";
import { Button } from "@/shared/components/ui/button";
import { KnowledgePointPicker } from "../knowledge-point-picker";
interface Props {
data: NewTeachingBlockData;
textbookId?: string;
chapterId?: string;
onUpdate: (data: NewTeachingBlockData) => void;
}
export function NewTeachingBlock({ data, textbookId, chapterId, onUpdate }: Props) {
const t = useTranslations("lessonPreparation");
const [pickerFor, setPickerFor] = useState<number | null>(null);
function updatePoint(index: number, patch: Partial<NewTeachingPoint>) {
const next = data.teachingPoints.map((it, i) =>
i === index ? { ...it, ...patch } : it,
);
onUpdate({ ...data, teachingPoints: next });
}
function addPoint() {
onUpdate({
...data,
teachingPoints: [
...data.teachingPoints,
{ knowledgePointIds: [], outline: "", boardNotes: "" },
],
});
}
function removePoint(index: number) {
onUpdate({
...data,
teachingPoints: data.teachingPoints.filter((_, i) => i !== index),
});
}
return (
<div className="space-y-3">
<div className="text-xs text-on-surface-variant">
{t("newTeaching.hint")}
</div>
{data.teachingPoints.map((point, idx) => (
<div key={idx} className="border border-outline-variant rounded p-2 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium">
{t("newTeaching.pointIndex", { index: idx + 1 })}
</span>
<Button
variant="ghost"
size="sm"
className="!p-1 text-error"
onClick={() => removePoint(idx)}
aria-label={t("action.delete")}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
<div>
<label className="text-xs block mb-1">
{t("newTeaching.outlineLabel")}
</label>
<textarea
value={point.outline}
onChange={(e) => updatePoint(idx, { outline: e.target.value })}
className="w-full text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[60px]"
placeholder={t("newTeaching.outlinePlaceholder")}
/>
</div>
<div>
<label className="text-xs block mb-1">
{t("newTeaching.boardNotesLabel")}
</label>
<textarea
value={point.boardNotes}
onChange={(e) => updatePoint(idx, { boardNotes: e.target.value })}
className="w-full text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[40px]"
placeholder={t("newTeaching.boardNotesPlaceholder")}
/>
</div>
<div className="flex items-center gap-2 flex-wrap">
{point.knowledgePointIds.length > 0 && (
<span className="text-xs text-on-surface-variant">
{t("knowledgePoint.linked", { count: point.knowledgePointIds.length })}
</span>
)}
<button
onClick={() => setPickerFor(idx)}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
>
<Tag className="w-3 h-3" />
{t("knowledgePoint.annotate")}
</button>
</div>
</div>
))}
<Button variant="outline" size="sm" onClick={addPoint}>
<Plus className="w-3 h-3 mr-1" />
{t("newTeaching.addPoint")}
</Button>
{pickerFor !== null && (
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={data.teachingPoints[pickerFor]?.knowledgePointIds ?? []}
onChange={(ids) => updatePoint(pickerFor, { knowledgePointIds: ids })}
onClose={() => setPickerFor(null)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { useTranslations } from "next-intl";
import { Plus, Trash2 } from "lucide-react";
import type { ObjectiveBlockData, ObjectiveItem } from "../../types";
import { Button } from "@/shared/components/ui/button";
interface Props {
data: ObjectiveBlockData;
onUpdate: (data: ObjectiveBlockData) => void;
}
const DIMENSIONS: ObjectiveItem["dimension"][] = ["knowledge", "process", "emotion"];
export function ObjectiveBlock({ data, onUpdate }: Props) {
const t = useTranslations("lessonPreparation");
function updateItem(index: number, patch: Partial<ObjectiveItem>) {
const next = data.objectives.map((it, i) =>
i === index ? { ...it, ...patch } : it,
);
onUpdate({ ...data, objectives: next });
}
function addItem() {
onUpdate({
...data,
objectives: [
...data.objectives,
{ dimension: "knowledge", text: "" },
],
});
}
function removeItem(index: number) {
onUpdate({
...data,
objectives: data.objectives.filter((_, i) => i !== index),
});
}
return (
<div className="space-y-2">
<div className="text-xs text-on-surface-variant">
{t("objective.hint")}
</div>
{data.objectives.map((item, idx) => (
<div key={idx} className="flex items-start gap-2">
<select
value={item.dimension}
onChange={(e) =>
updateItem(idx, {
dimension: e.target.value as ObjectiveItem["dimension"],
})
}
className="text-xs border border-outline-variant rounded px-1 py-1 bg-surface"
>
{DIMENSIONS.map((d) => (
<option key={d} value={d}>
{t(`objective.dimension.${d}`)}
</option>
))}
</select>
<textarea
value={item.text}
onChange={(e) => updateItem(idx, { text: e.target.value })}
className="flex-1 text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[40px]"
placeholder={t("objective.textPlaceholder")}
/>
<Button
variant="ghost"
size="sm"
className="!p-1 text-error"
onClick={() => removeItem(idx)}
aria-label={t("action.delete")}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={addItem}>
<Plus className="w-3 h-3 mr-1" />
{t("objective.addItem")}
</Button>
</div>
);
}

View File

@@ -1,16 +1,84 @@
"use client";
import { useTranslations } from "next-intl";
import { RichTextBlock } from "./rich-text-block";
import type { RichTextBlockData } from "../../types";
import { Plus, Trash2 } from "lucide-react";
import type { ReflectionBlockData, ReflectionItem } from "../../types";
import { Button } from "@/shared/components/ui/button";
interface Props {
data: RichTextBlockData;
onUpdate: (data: RichTextBlockData) => void;
data: ReflectionBlockData;
onUpdate: (data: ReflectionBlockData) => void;
}
export function ReflectionBlock(props: Props) {
const ASPECTS: ReflectionItem["aspect"][] = ["effectiveness", "problems", "improvements"];
export function ReflectionBlock({ data, onUpdate }: Props) {
const t = useTranslations("lessonPreparation");
// 教学反思在 P1 阶段与普通富文本一致P3 再扩展学情数据嵌入
return <RichTextBlock {...props} hint={t("reflection.hint")} />;
function updateItem(index: number, patch: Partial<ReflectionItem>) {
const next = data.reflection.map((it, i) =>
i === index ? { ...it, ...patch } : it,
);
onUpdate({ ...data, reflection: next });
}
function addItem() {
onUpdate({
...data,
reflection: [...data.reflection, { aspect: "effectiveness", text: "" }],
});
}
function removeItem(index: number) {
onUpdate({
...data,
reflection: data.reflection.filter((_, i) => i !== index),
});
}
return (
<div className="space-y-2">
<div className="text-xs text-on-surface-variant">
{t("reflection.hint")}
</div>
{data.reflection.map((item, idx) => (
<div key={idx} className="flex items-start gap-2">
<select
value={item.aspect}
onChange={(e) =>
updateItem(idx, {
aspect: e.target.value as ReflectionItem["aspect"],
})
}
className="text-xs border border-outline-variant rounded px-1 py-1 bg-surface"
>
{ASPECTS.map((a) => (
<option key={a} value={a}>
{t(`reflection.aspect.${a}`)}
</option>
))}
</select>
<textarea
value={item.text}
onChange={(e) => updateItem(idx, { text: e.target.value })}
className="flex-1 text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[60px]"
placeholder={t("reflection.textPlaceholder")}
/>
<Button
variant="ghost"
size="sm"
className="!p-1 text-error"
onClick={() => removeItem(idx)}
aria-label={t("action.delete")}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={addItem}>
<Plus className="w-3 h-3 mr-1" />
{t("reflection.addItem")}
</Button>
</div>
);
}

View File

@@ -0,0 +1,78 @@
"use client";
import { useTranslations } from "next-intl";
import { Plus, Trash2 } from "lucide-react";
import type { SummaryBlockData } from "../../types";
import { Button } from "@/shared/components/ui/button";
interface Props {
data: SummaryBlockData;
onUpdate: (data: SummaryBlockData) => void;
}
export function SummaryBlock({ data, onUpdate }: Props) {
const t = useTranslations("lessonPreparation");
function updatePoint(index: number, value: string) {
const next = data.summaryPoints.map((p, i) => (i === index ? value : p));
onUpdate({ ...data, summaryPoints: next });
}
function addPoint() {
onUpdate({
...data,
summaryPoints: [...data.summaryPoints, ""],
});
}
function removePoint(index: number) {
onUpdate({
...data,
summaryPoints: data.summaryPoints.filter((_, i) => i !== index),
});
}
return (
<div className="space-y-2">
<div className="text-xs text-on-surface-variant">
{t("summary.hint")}
</div>
{data.summaryPoints.map((point, idx) => (
<div key={idx} className="flex items-start gap-2">
<span className="text-xs text-on-surface-variant mt-1">{idx + 1}.</span>
<input
type="text"
value={point}
onChange={(e) => updatePoint(idx, e.target.value)}
className="flex-1 text-sm border border-outline-variant rounded px-2 py-1"
placeholder={t("summary.pointPlaceholder")}
/>
<Button
variant="ghost"
size="sm"
className="!p-1 text-error"
onClick={() => removePoint(idx)}
aria-label={t("action.delete")}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={addPoint}>
<Plus className="w-3 h-3 mr-1" />
{t("summary.addPoint")}
</Button>
<div>
<label className="text-xs font-medium block mb-1 mt-2">
{t("summary.homeworkPreviewLabel")}
</label>
<textarea
value={data.homeworkPreview}
onChange={(e) => onUpdate({ ...data, homeworkPreview: e.target.value })}
className="w-full text-sm border border-outline-variant rounded px-2 py-1 resize-y min-h-[40px]"
placeholder={t("summary.homeworkPreviewPlaceholder")}
/>
</div>
</div>
);
}