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:
@@ -27,7 +27,7 @@ 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, ExerciseBlockData, TextStudyBlockData } from "../types";
|
||||
import type { LessonPlanNode, RichTextBlockData, ExerciseBlockData, TextStudyBlockData, ReflectionBlockData } from "../types";
|
||||
|
||||
interface BlockRendererProps {
|
||||
textbookId?: string;
|
||||
@@ -122,7 +122,7 @@ function SortableBlock({
|
||||
/>
|
||||
) : node.type === "reflection" ? (
|
||||
<ReflectionBlock
|
||||
data={node.data as RichTextBlockData}
|
||||
data={node.data as ReflectionBlockData}
|
||||
onUpdate={(d) => updateNode(node.id, { data: d })}
|
||||
/>
|
||||
) : (
|
||||
@@ -140,7 +140,7 @@ export function BlockRenderer({
|
||||
chapterId,
|
||||
classes,
|
||||
}: BlockRendererProps) {
|
||||
const { doc } = useLessonPlanEditor();
|
||||
const { doc, updateNode } = useLessonPlanEditor();
|
||||
|
||||
function onDragEnd(e: DragEndEvent) {
|
||||
const { active, over } = e;
|
||||
@@ -149,11 +149,10 @@ export function BlockRenderer({
|
||||
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;
|
||||
// 交换 order 并写回 store(修复 onDragEnd 未回写 store 的 BUG)
|
||||
const tmpOrder = doc.nodes[oldIndex].order;
|
||||
updateNode(doc.nodes[oldIndex].id, { order: doc.nodes[newIndex].order });
|
||||
updateNode(doc.nodes[newIndex].id, { order: tmpOrder });
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -163,17 +162,19 @@ export function BlockRenderer({
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
{doc.nodes
|
||||
.filter((b): b is LessonPlanNode => b.type !== "textbook_content")
|
||||
.map((b, i) => (
|
||||
<SortableBlock
|
||||
key={b.id}
|
||||
node={b}
|
||||
index={i}
|
||||
total={doc.nodes.length}
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
classes={classes}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -29,15 +29,42 @@ export function KnowledgePointPicker({
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const [options, setOptions] = useState<KpOption[]>([]);
|
||||
const [local, setLocal] = useState<string[]>(selectedIds);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textbookId) {
|
||||
return;
|
||||
}
|
||||
getKnowledgePointOptionsAction({ textbookId, chapterId }).then((res) => {
|
||||
if (res.success && res.data) setOptions(res.data.options);
|
||||
});
|
||||
}, [textbookId, chapterId]);
|
||||
let cancelled = false;
|
||||
// 使用 Promise.resolve().then() 避免在 effect 中同步调用 setState
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
return getKnowledgePointOptionsAction({ textbookId, chapterId });
|
||||
})
|
||||
.then((res) => {
|
||||
if (cancelled || !res) return;
|
||||
if (res.success && res.data) {
|
||||
setOptions(res.data.options);
|
||||
} else {
|
||||
setError(res.message ?? t("error.loadFailed"));
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (cancelled) return;
|
||||
console.error("[KnowledgePointPicker] load options failed", e);
|
||||
setError(t("error.loadFailed"));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [textbookId, chapterId, t]);
|
||||
|
||||
function toggle(id: string) {
|
||||
setLocal((prev) =>
|
||||
@@ -60,7 +87,13 @@ export function KnowledgePointPicker({
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{options.length === 0 ? (
|
||||
{loading ? (
|
||||
<p className="text-on-surface-variant text-sm">
|
||||
{t("knowledgePoint.loading")}
|
||||
</p>
|
||||
) : error ? (
|
||||
<p className="text-error text-sm">{error}</p>
|
||||
) : options.length === 0 ? (
|
||||
<p className="text-on-surface-variant text-sm">
|
||||
{t("knowledgePoint.empty")}
|
||||
</p>
|
||||
|
||||
@@ -32,25 +32,37 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
||||
const service = ctx?.service ?? null;
|
||||
|
||||
async function handleArchive() {
|
||||
const res = service
|
||||
? await service.deleteLessonPlan(plan.id)
|
||||
: await deleteLessonPlanAction(plan.id);
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.archive", { planId: plan.id });
|
||||
toast.success(t("status.archived"));
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(res.message ?? t("error.delete"));
|
||||
try {
|
||||
const res = service
|
||||
? await service.deleteLessonPlan(plan.id)
|
||||
: await deleteLessonPlanAction(plan.id);
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.archive", { planId: plan.id });
|
||||
toast.success(t("status.archived"));
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(res.message ?? t("error.delete"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LessonPlanCard] archive failed", e);
|
||||
toast.error(t("error.delete"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDuplicate() {
|
||||
const res = service
|
||||
? await service.duplicateLessonPlan(plan.id)
|
||||
: await duplicateLessonPlanAction(plan.id);
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.duplicate", { planId: plan.id });
|
||||
router.refresh();
|
||||
try {
|
||||
const res = service
|
||||
? await service.duplicateLessonPlan(plan.id)
|
||||
: await duplicateLessonPlanAction(plan.id);
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.duplicate", { planId: plan.id });
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(res.message ?? t("error.duplicate"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LessonPlanCard] duplicate failed", e);
|
||||
toast.error(t("error.duplicate"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||
import type { BlockType } from "../types";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Plus, Save, History } from "lucide-react";
|
||||
import { Plus, Save, History, Book, FileText } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
planId: string;
|
||||
@@ -22,6 +22,8 @@ interface Props {
|
||||
initialDoc: import("../types").LessonPlanDocument;
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
textbookTitle?: string;
|
||||
chapterTitle?: string;
|
||||
classes?: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
@@ -46,6 +48,8 @@ export function LessonPlanEditor({
|
||||
initialDoc,
|
||||
textbookId,
|
||||
chapterId,
|
||||
textbookTitle,
|
||||
chapterTitle,
|
||||
classes,
|
||||
}: Props) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
@@ -71,13 +75,18 @@ export function LessonPlanEditor({
|
||||
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();
|
||||
try {
|
||||
const res = await updateLessonPlanAction({
|
||||
planId: state.planId,
|
||||
title: state.title,
|
||||
content: state.doc,
|
||||
});
|
||||
if (res.success) state.markSaved();
|
||||
} catch (e) {
|
||||
console.error("[LessonPlanEditor] auto-save failed", e);
|
||||
} finally {
|
||||
state.setSaving(false);
|
||||
}
|
||||
}, 3000);
|
||||
return () => {
|
||||
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
||||
@@ -89,11 +98,15 @@ export function LessonPlanEditor({
|
||||
versionTimer.current = setInterval(async () => {
|
||||
const state = useLessonPlanEditor.getState();
|
||||
if (!state.isDirty) return;
|
||||
await saveLessonPlanVersionAction({
|
||||
planId: state.planId,
|
||||
content: state.doc,
|
||||
label: t("version.autoLabel"),
|
||||
});
|
||||
try {
|
||||
await saveLessonPlanVersionAction({
|
||||
planId: state.planId,
|
||||
content: state.doc,
|
||||
label: t("version.autoLabel"),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[LessonPlanEditor] auto-version failed", e);
|
||||
}
|
||||
}, 30 * 60 * 1000);
|
||||
return () => {
|
||||
if (versionTimer.current) clearInterval(versionTimer.current);
|
||||
@@ -127,23 +140,32 @@ export function LessonPlanEditor({
|
||||
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();
|
||||
tracker.track("lesson_plan.save", { planId: state.planId, source: "manual" });
|
||||
try {
|
||||
const res = await saveLessonPlanVersionAction({
|
||||
planId: state.planId,
|
||||
content: state.doc,
|
||||
});
|
||||
if (res.success) {
|
||||
state.markSaved();
|
||||
tracker.track("lesson_plan.save", { planId: state.planId, source: "manual" });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LessonPlanEditor] manual save failed", e);
|
||||
} finally {
|
||||
state.setSaving(false);
|
||||
}
|
||||
}, [tracker]);
|
||||
|
||||
// 版本回退后刷新内容(修复 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);
|
||||
try {
|
||||
const res = await getLessonPlanByIdAction(state.planId);
|
||||
if (res.success && res.data?.plan) {
|
||||
state.hydrate(state.planId, res.data.plan.title, res.data.plan.content);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LessonPlanEditor] reload after revert failed", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -156,6 +178,20 @@ export function LessonPlanEditor({
|
||||
onChange={(e) => editor.setTitle(e.target.value)}
|
||||
className="flex-1 bg-transparent font-headline-md text-headline-md focus:outline-none"
|
||||
/>
|
||||
{/* 教材/章节指示器 */}
|
||||
{textbookTitle && (
|
||||
<div className="flex items-center gap-1 text-xs text-on-surface-variant px-2 py-1 rounded bg-surface-container-high">
|
||||
<Book className="w-3 h-3" />
|
||||
<span className="max-w-[120px] truncate">{textbookTitle}</span>
|
||||
{chapterTitle && (
|
||||
<>
|
||||
<span className="text-on-surface-variant/50">/</span>
|
||||
<FileText className="w-3 h-3" />
|
||||
<span className="max-w-[120px] truncate">{chapterTitle}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-on-surface-variant text-sm">
|
||||
{editor.isSaving
|
||||
? t("status.saving")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useDebounce } from "@/shared/hooks/use-debounce";
|
||||
|
||||
@@ -21,13 +21,19 @@ export function LessonPlanFilters({ onFilter, subjects }: Props) {
|
||||
// 修复 P1-6:搜索 debounce 300ms
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
|
||||
// 使用 ref 存储 onFilter,避免其引用变化触发 useEffect 无限循环
|
||||
const onFilterRef = useRef(onFilter);
|
||||
useEffect(() => {
|
||||
onFilter({
|
||||
onFilterRef.current = onFilter;
|
||||
}, [onFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
onFilterRef.current({
|
||||
query: debouncedQuery || undefined,
|
||||
subjectId: subjectId || undefined,
|
||||
status: status || undefined,
|
||||
});
|
||||
}, [debouncedQuery, subjectId, status, onFilter]);
|
||||
}, [debouncedQuery, subjectId, status]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { LessonPlanCard } from "./lesson-plan-card";
|
||||
import { LessonPlanFilters } from "./lesson-plan-filters";
|
||||
@@ -16,26 +16,50 @@ interface Props {
|
||||
export function LessonPlanList({ initialItems, subjects }: Props) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const [items, setItems] = useState(initialItems);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const ctx = useLessonPlanContextSafe();
|
||||
const service = ctx?.service ?? null;
|
||||
|
||||
async function handleFilter(params: {
|
||||
query?: string;
|
||||
subjectId?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
if (service) {
|
||||
const res = await service.getLessonPlans(params);
|
||||
if (res.success && res.data) setItems(res.data.items);
|
||||
return;
|
||||
}
|
||||
const res = await getLessonPlansAction(params);
|
||||
if (res.success && res.data) setItems(res.data.items);
|
||||
}
|
||||
// 使用 useCallback 稳定 handleFilter 引用,避免 LessonPlanFilters 的 useEffect 无限循环
|
||||
const handleFilter = useCallback(
|
||||
async (params: {
|
||||
query?: string;
|
||||
subjectId?: string;
|
||||
status?: string;
|
||||
}) => {
|
||||
setError(null);
|
||||
try {
|
||||
if (service) {
|
||||
const res = await service.getLessonPlans(params);
|
||||
if (res.success && res.data) {
|
||||
setItems(res.data.items);
|
||||
} else {
|
||||
setError(res.message ?? t("error.loadFailed"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const res = await getLessonPlansAction(params);
|
||||
if (res.success && res.data) {
|
||||
setItems(res.data.items);
|
||||
} else {
|
||||
setError(res.message ?? t("error.loadFailed"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[LessonPlanList] filter failed", e);
|
||||
setError(t("error.loadFailed"));
|
||||
}
|
||||
},
|
||||
[service, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<LessonPlanFilters onFilter={handleFilter} subjects={subjects} />
|
||||
{error && (
|
||||
<p className="text-error text-sm bg-error-container/10 px-3 py-2 rounded">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{items.length === 0 ? (
|
||||
<p className="text-on-surface-variant text-center py-12">
|
||||
{t("list.empty")}
|
||||
|
||||
@@ -18,39 +18,108 @@ import {
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||||
import { LessonNode } from "./nodes/lesson-node";
|
||||
import { TextbookContentNode as TextbookContentNodeComponent } from "./nodes/textbook-content-node";
|
||||
import { toRfNodes, toRfEdges } from "../lib/rf-mappers";
|
||||
import { getNodeColor } from "../lib/node-summary";
|
||||
import type { LessonPlanNode } from "../types";
|
||||
import type { AnyLessonPlanNode } from "../types";
|
||||
|
||||
const nodeTypes = { lesson: LessonNode };
|
||||
const nodeTypes = {
|
||||
lesson: LessonNode,
|
||||
textbook_content: TextbookContentNodeComponent,
|
||||
};
|
||||
|
||||
// NodeEditor 只负责画布交互,内容编辑由 NodeEditPanel 处理
|
||||
type Props = Record<string, never>;
|
||||
|
||||
export function NodeEditor({}: Props) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const { doc, selectedNodeId, updateNodePosition, removeNode, connect, selectNode, setEdges } =
|
||||
useLessonPlanEditor();
|
||||
const {
|
||||
doc,
|
||||
selectedNodeId,
|
||||
updateNodePosition,
|
||||
removeNode,
|
||||
connect,
|
||||
selectNode,
|
||||
setEdges,
|
||||
addAnchor,
|
||||
updateTextbookContent,
|
||||
} = useLessonPlanEditor();
|
||||
|
||||
// 锚点添加回调(正文节点使用)
|
||||
const handleAddRangeAnchor = useCallback(
|
||||
(params: { nodeId: string; start: number; end: number; textPreview: string }) => {
|
||||
// 如果 nodeId 是 __selected__,使用当前选中节点
|
||||
// 如果是 __new__,提示用户先创建节点
|
||||
const actualNodeId =
|
||||
params.nodeId === "__selected__"
|
||||
? selectedNodeId ?? ""
|
||||
: params.nodeId;
|
||||
if (!actualNodeId || actualNodeId === "__new__") {
|
||||
// 简化:不自动创建新节点,提示用户先选中或创建
|
||||
return;
|
||||
}
|
||||
addAnchor({
|
||||
nodeId: actualNodeId,
|
||||
type: "range",
|
||||
start: params.start,
|
||||
end: params.end,
|
||||
textPreview: params.textPreview,
|
||||
});
|
||||
},
|
||||
[addAnchor, selectedNodeId],
|
||||
);
|
||||
|
||||
const handleAddPointAnchor = useCallback(
|
||||
(params: { nodeId: string; start: number }) => {
|
||||
const actualNodeId =
|
||||
params.nodeId === "__selected__"
|
||||
? selectedNodeId ?? ""
|
||||
: params.nodeId;
|
||||
if (!actualNodeId || actualNodeId === "__new__") {
|
||||
return;
|
||||
}
|
||||
addAnchor({
|
||||
nodeId: actualNodeId,
|
||||
type: "point",
|
||||
start: params.start,
|
||||
});
|
||||
},
|
||||
[addAnchor, selectedNodeId],
|
||||
);
|
||||
|
||||
const handleZoomChange = useCallback(
|
||||
(zoom: number) => {
|
||||
updateTextbookContent({ zoom });
|
||||
},
|
||||
[updateTextbookContent],
|
||||
);
|
||||
|
||||
// 使用纯函数映射 nodes/edges
|
||||
const rfNodes: Node[] = useMemo(
|
||||
() => toRfNodes(doc.nodes, selectedNodeId),
|
||||
[doc.nodes, selectedNodeId],
|
||||
() =>
|
||||
toRfNodes(doc.nodes, selectedNodeId, {
|
||||
anchors: doc.anchors,
|
||||
selectedNodeId,
|
||||
onAddRangeAnchor: handleAddRangeAnchor,
|
||||
onAddPointAnchor: handleAddPointAnchor,
|
||||
onSelectNode: selectNode,
|
||||
onZoomChange: handleZoomChange,
|
||||
}),
|
||||
[doc.nodes, doc.anchors, selectedNodeId, handleAddRangeAnchor, handleAddPointAnchor, selectNode, handleZoomChange],
|
||||
);
|
||||
|
||||
const rfEdges: Edge[] = useMemo(
|
||||
() => toRfEdges(doc.edges),
|
||||
[doc.edges],
|
||||
() => toRfEdges(doc.edges, selectedNodeId, doc.anchors),
|
||||
[doc.edges, selectedNodeId, doc.anchors],
|
||||
);
|
||||
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
changes.forEach((change) => {
|
||||
if (change.type === "position" && change.position) {
|
||||
// 拖拽结束时(dragging: false)才写入最终位置,避免中间状态污染(修复 P1-1)
|
||||
if (change.dragging === false) {
|
||||
updateNodePosition(change.id, change.position);
|
||||
}
|
||||
// 实时拖动:每次 position 变化都更新(不再等待 dragging=false)
|
||||
// 但仅在节点正在被拖动或拖动结束时更新
|
||||
updateNodePosition(change.id, change.position);
|
||||
} else if (change.type === "remove") {
|
||||
removeNode(change.id);
|
||||
} else if (change.type === "select") {
|
||||
@@ -75,16 +144,32 @@ export function NodeEditor({}: Props) {
|
||||
(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,
|
||||
}));
|
||||
const ourEdges = nextEdges.map((e) => {
|
||||
// 保留原有的 type 信息
|
||||
const original = doc.edges.find((oe) => oe.id === e.id);
|
||||
if (original?.type === "anchor") {
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle: e.sourceHandle ?? null,
|
||||
targetHandle: e.targetHandle ?? null,
|
||||
type: "anchor" as const,
|
||||
anchorId: original.anchorId,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle: e.sourceHandle ?? null,
|
||||
targetHandle: e.targetHandle ?? null,
|
||||
type: "flow" as const,
|
||||
};
|
||||
});
|
||||
setEdges(ourEdges);
|
||||
},
|
||||
[rfEdges, setEdges],
|
||||
[rfEdges, setEdges, doc.edges],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -131,7 +216,7 @@ export function NodeEditor({}: Props) {
|
||||
<MiniMap
|
||||
className="!bg-surface !border-outline-variant"
|
||||
nodeColor={(n) => {
|
||||
const nodeData = (n.data as { node?: LessonPlanNode }).node;
|
||||
const nodeData = (n.data as { node?: AnyLessonPlanNode }).node;
|
||||
if (!nodeData) return "#9e9e9e";
|
||||
return getNodeColor(nodeData.type);
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useMemo, useRef, useCallback, useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { NodeProps } from "@xyflow/react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import { ZoomIn, ZoomOut } from "lucide-react";
|
||||
|
||||
import type { NodeAnchor, TextbookContentNode as TextbookContentNodeModel } from "../../types";
|
||||
import {
|
||||
injectPlaceholders,
|
||||
parseAnchoredText,
|
||||
toCircledNumber,
|
||||
getNextPointIndex,
|
||||
} from "../../lib/anchor-injector";
|
||||
import { getNodeColor } from "../../lib/node-summary";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
|
||||
interface TextbookContentNodeProps {
|
||||
data: {
|
||||
node: TextbookContentNodeModel;
|
||||
anchors: NodeAnchor[];
|
||||
selectedNodeId: string | null;
|
||||
onAddRangeAnchor?: (params: {
|
||||
nodeId: string;
|
||||
start: number;
|
||||
end: number;
|
||||
textPreview: string;
|
||||
}) => void;
|
||||
onAddPointAnchor?: (params: {
|
||||
nodeId: string;
|
||||
start: number;
|
||||
}) => void;
|
||||
onSelectNode?: (id: string | null) => void;
|
||||
onZoomChange?: (zoom: number) => void;
|
||||
};
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export const TextbookContentNode = memo(function TextbookContentNode({
|
||||
data,
|
||||
selected,
|
||||
}: NodeProps) {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const props = (data as unknown as TextbookContentNodeProps["data"]).node
|
||||
? (data as unknown as TextbookContentNodeProps["data"])
|
||||
: null;
|
||||
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [showAnchorMenu, setShowAnchorMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
selection: { start: number; end: number; text: string } | null;
|
||||
point: number | null;
|
||||
} | null>(null);
|
||||
|
||||
const node = props?.node;
|
||||
const anchors = useMemo(() => props?.anchors ?? [], [props?.anchors]);
|
||||
const selectedNodeId = props?.selectedNodeId ?? null;
|
||||
|
||||
// 注入锚点标记后的 Markdown
|
||||
const injectedContent = useMemo(() => {
|
||||
if (!node) return "";
|
||||
return injectPlaceholders(node.data.content, anchors);
|
||||
}, [node, anchors]);
|
||||
|
||||
// 解析为段落数组(用于自定义渲染)
|
||||
const segments = useMemo(
|
||||
() => parseAnchoredText(injectedContent),
|
||||
[injectedContent],
|
||||
);
|
||||
|
||||
// 选中节点的激活锚点 ID 集合
|
||||
const activeAnchorIds = useMemo(() => {
|
||||
if (!selectedNodeId) return new Set<string>();
|
||||
return new Set(
|
||||
anchors.filter((a) => a.nodeId === selectedNodeId).map((a) => a.id),
|
||||
);
|
||||
}, [anchors, selectedNodeId]);
|
||||
|
||||
// 获取锚点对应的节点颜色
|
||||
const getAnchorNodeColor = useCallback(
|
||||
(anchorId: string): string => {
|
||||
const anchor = anchors.find((a) => a.id === anchorId);
|
||||
if (!anchor) return "#9e9e9e";
|
||||
return getNodeColor(anchor.nodeId);
|
||||
},
|
||||
[anchors],
|
||||
);
|
||||
|
||||
// 处理文本选择
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!node) return;
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed) {
|
||||
// 点击空白处:尝试计算点击位置偏移
|
||||
return;
|
||||
}
|
||||
|
||||
const text = selection.toString();
|
||||
if (!text) return;
|
||||
|
||||
// 计算纯文本偏移量
|
||||
const range = selection.getRangeAt(0);
|
||||
const plainText = node.data.content;
|
||||
const startContainer = range.startContainer;
|
||||
const endContainer = range.endContainer;
|
||||
|
||||
// 简化:用 selection 的 anchorOffset 和 focusOffset
|
||||
// 注意:这是近似值,对于复杂 DOM 结构可能不准确
|
||||
const startOffset = range.startOffset;
|
||||
const endOffset = range.endOffset;
|
||||
|
||||
// 如果在同一文本节点
|
||||
if (startContainer === endContainer && startContainer.nodeType === Node.TEXT_NODE) {
|
||||
const containerText = startContainer.textContent ?? "";
|
||||
const containerStart = plainText.indexOf(containerText);
|
||||
if (containerStart >= 0) {
|
||||
const absoluteStart = containerStart + startOffset;
|
||||
const absoluteEnd = containerStart + endOffset;
|
||||
const selectedText = plainText.slice(absoluteStart, absoluteEnd);
|
||||
|
||||
// 显示锚点菜单
|
||||
const rect = range.getBoundingClientRect();
|
||||
setShowAnchorMenu({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top - 10,
|
||||
selection: { start: absoluteStart, end: absoluteEnd, text: selectedText },
|
||||
point: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
selection.removeAllRanges();
|
||||
}, [node]);
|
||||
|
||||
// 处理点击(点锚定)
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!node) return;
|
||||
// 如果有选中文本,不处理点击
|
||||
const selection = window.getSelection();
|
||||
if (selection && !selection.isCollapsed) return;
|
||||
|
||||
// 计算点击位置在纯文本中的偏移
|
||||
// 简化:使用 caretRangeFromPoint(Chromium)或 caretPositionFromPoint(Firefox)
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
let offset = -1;
|
||||
|
||||
if (document.caretPositionFromPoint) {
|
||||
const pos = document.caretPositionFromPoint(x, y);
|
||||
if (pos) offset = pos.offset;
|
||||
} else if (document.caretRangeFromPoint) {
|
||||
const range = document.caretRangeFromPoint(x, y);
|
||||
if (range) offset = range.startOffset;
|
||||
}
|
||||
|
||||
if (offset < 0) return;
|
||||
|
||||
setShowAnchorMenu({
|
||||
x,
|
||||
y,
|
||||
selection: null,
|
||||
point: offset,
|
||||
});
|
||||
},
|
||||
[node],
|
||||
);
|
||||
|
||||
// 关闭锚点菜单
|
||||
useEffect(() => {
|
||||
if (!showAnchorMenu) return;
|
||||
function handleOutside(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest("[data-anchor-menu]")) {
|
||||
setShowAnchorMenu(null);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleOutside);
|
||||
return () => document.removeEventListener("mousedown", handleOutside);
|
||||
}, [showAnchorMenu]);
|
||||
|
||||
// 缩放控制
|
||||
const handleZoomIn = useCallback(() => {
|
||||
if (!node || !props?.onZoomChange) return;
|
||||
const newZoom = Math.min(2, node.data.zoom + 0.1);
|
||||
props.onZoomChange(newZoom);
|
||||
}, [node, props]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
if (!node || !props?.onZoomChange) return;
|
||||
const newZoom = Math.max(0.5, node.data.zoom - 0.1);
|
||||
props.onZoomChange(newZoom);
|
||||
}, [node, props]);
|
||||
|
||||
if (!node) {
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-outline-variant bg-surface p-4 text-on-surface-variant">
|
||||
{t("editor.textbookContentMissing")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nextPointNumber = getNextPointIndex(anchors);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border-2 bg-surface shadow-lg"
|
||||
style={{
|
||||
borderColor: selected ? "#1976d2" : "#455a64",
|
||||
boxShadow: selected ? "0 0 0 2px rgba(25,118,210,0.3)" : undefined,
|
||||
width: 480,
|
||||
}}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div
|
||||
className="px-3 py-2 rounded-t-md text-white text-xs font-medium flex items-center justify-between"
|
||||
style={{ backgroundColor: "#455a64" }}
|
||||
>
|
||||
<span>{t("editor.textbookContent")}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="!p-1 !h-6 !w-6 text-white hover:bg-white/20"
|
||||
onClick={handleZoomOut}
|
||||
aria-label={t("editor.zoomOut")}
|
||||
>
|
||||
<ZoomOut className="w-3 h-3" />
|
||||
</Button>
|
||||
<span className="text-xs">{Math.round(node.data.zoom * 100)}%</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="!p-1 !h-6 !w-6 text-white hover:bg-white/20"
|
||||
onClick={handleZoomIn}
|
||||
aria-label={t("editor.zoomIn")}
|
||||
>
|
||||
<ZoomIn className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 正文内容 */}
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="px-4 py-3 max-h-[60vh] overflow-y-auto"
|
||||
style={{
|
||||
transform: `scale(${node.data.zoom})`,
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
onMouseUp={handleMouseUp}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{node.data.content ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
components={{
|
||||
p: ({ children }) => {
|
||||
// 将段落中的锚点标记渲染为 span
|
||||
return <p>{renderChildrenWithAnchors(children, segments, activeAnchorIds, getAnchorNodeColor, props?.onSelectNode, anchors)}</p>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{injectedContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-on-surface-variant text-sm py-8 text-center">
|
||||
{t("editor.textbookContentEmpty")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 锚点浮动菜单 */}
|
||||
{showAnchorMenu && (
|
||||
<div
|
||||
data-anchor-menu
|
||||
className="fixed z-50 bg-surface border border-outline-variant rounded-lg shadow-lg p-2 min-w-[200px]"
|
||||
style={{
|
||||
left: showAnchorMenu.x,
|
||||
top: showAnchorMenu.y,
|
||||
transform: "translate(-50%, -100%)",
|
||||
}}
|
||||
>
|
||||
{showAnchorMenu.selection ? (
|
||||
<div>
|
||||
<div className="text-xs text-on-surface-variant mb-2 px-2">
|
||||
{t("editor.rangeAnchorTitle")}
|
||||
</div>
|
||||
<AnchorNodeSelector
|
||||
t={t}
|
||||
onSelect={(nodeId) => {
|
||||
if (props?.onAddRangeAnchor && showAnchorMenu.selection) {
|
||||
props.onAddRangeAnchor({
|
||||
nodeId,
|
||||
start: showAnchorMenu.selection.start,
|
||||
end: showAnchorMenu.selection.end,
|
||||
textPreview: showAnchorMenu.selection.text,
|
||||
});
|
||||
}
|
||||
setShowAnchorMenu(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : showAnchorMenu.point !== null ? (
|
||||
<div>
|
||||
<div className="text-xs text-on-surface-variant mb-2 px-2">
|
||||
{t("editor.pointAnchorTitle", { number: toCircledNumber(nextPointNumber) })}
|
||||
</div>
|
||||
<AnchorNodeSelector
|
||||
t={t}
|
||||
onSelect={(nodeId) => {
|
||||
if (props?.onAddPointAnchor && showAnchorMenu.point !== null) {
|
||||
props.onAddPointAnchor({
|
||||
nodeId,
|
||||
start: showAnchorMenu.point,
|
||||
});
|
||||
}
|
||||
setShowAnchorMenu(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 锚点节点选择器(简化版:由父组件传入节点列表)
|
||||
* 实际节点列表通过 context 或 props 传入,这里仅渲染触发按钮
|
||||
*/
|
||||
function AnchorNodeSelector({
|
||||
t,
|
||||
onSelect,
|
||||
}: {
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
onSelect: (nodeId: string) => void;
|
||||
}) {
|
||||
// 简化:直接调用 onAddRangeAnchor/onAddPointAnchor 时由父组件决定 nodeId
|
||||
// 这里提供一个输入框让用户输入节点 ID 或选择
|
||||
// 实际实现中应从父组件获取可锚定节点列表
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
className="w-full text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded"
|
||||
onClick={() => onSelect("__selected__")}
|
||||
>
|
||||
{t("editor.anchorToSelectedNode")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded"
|
||||
onClick={() => onSelect("__new__")}
|
||||
>
|
||||
{t("editor.anchorToNewNode")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染带锚点标记的子节点。
|
||||
* 由于 ReactMarkdown 的 components 自定义渲染较为复杂,
|
||||
* 这里采用简化方案:在文本节点中查找锚点标记并替换为 span。
|
||||
*/
|
||||
function renderChildrenWithAnchors(
|
||||
children: React.ReactNode,
|
||||
segments: ReturnType<typeof parseAnchoredText>,
|
||||
activeAnchorIds: Set<string>,
|
||||
getAnchorNodeColor: (anchorId: string) => string,
|
||||
onSelectNode?: (id: string | null) => void,
|
||||
anchors?: NodeAnchor[],
|
||||
): React.ReactNode {
|
||||
// 简化:直接遍历 segments 渲染
|
||||
return segments.map((seg, idx) => {
|
||||
if (seg.type === "text") {
|
||||
return <span key={idx}>{seg.content}</span>;
|
||||
}
|
||||
if (seg.type === "anchor-range") {
|
||||
const isActive = seg.anchorId ? activeAnchorIds.has(seg.anchorId) : false;
|
||||
const color = seg.anchorId ? getAnchorNodeColor(seg.anchorId) : "#9e9e9e";
|
||||
const anchor = anchors?.find((a) => a.id === seg.anchorId);
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className={`range-anchor ${isActive ? "active" : ""}`}
|
||||
style={
|
||||
{
|
||||
backgroundColor: color,
|
||||
"--node-color": color,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (anchor && onSelectNode) {
|
||||
onSelectNode(anchor.nodeId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{seg.content}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// point anchor
|
||||
const isActive = seg.anchorId ? activeAnchorIds.has(seg.anchorId) : false;
|
||||
const color = seg.anchorId ? getAnchorNodeColor(seg.anchorId) : "#9e9e9e";
|
||||
const anchor = anchors?.find((a) => a.id === seg.anchorId);
|
||||
const pointIndex = anchor
|
||||
? (anchors?.filter((a) => a.type === "point").indexOf(anchor) ?? -1) + 1
|
||||
: 1;
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className={`point-anchor ${isActive ? "active" : ""}`}
|
||||
style={
|
||||
{
|
||||
backgroundColor: color,
|
||||
"--node-color": color,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (anchor && onSelectNode) {
|
||||
onSelectNode(anchor.nodeId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{toCircledNumber(pointIndex ?? 1)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -37,24 +37,30 @@ export function PublishHomeworkDialog({
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await publishLessonPlanHomeworkAction({
|
||||
planId,
|
||||
blockId,
|
||||
classIds: selectedClasses,
|
||||
availableAt: availableAt || undefined,
|
||||
dueAt: dueAt || undefined,
|
||||
});
|
||||
setLoading(false);
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.publish", {
|
||||
try {
|
||||
const res = await publishLessonPlanHomeworkAction({
|
||||
planId,
|
||||
blockId,
|
||||
classCount: selectedClasses.length,
|
||||
classIds: selectedClasses,
|
||||
availableAt: availableAt || undefined,
|
||||
dueAt: dueAt || undefined,
|
||||
});
|
||||
onPublished();
|
||||
onClose();
|
||||
} else {
|
||||
setError(res.message ?? t("error.publish"));
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.publish", {
|
||||
planId,
|
||||
blockId,
|
||||
classCount: selectedClasses.length,
|
||||
});
|
||||
onPublished();
|
||||
onClose();
|
||||
} else {
|
||||
setError(res.message ?? t("error.publish"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[PublishHomeworkDialog] publish failed", e);
|
||||
setError(t("error.publish"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
||||
const t = useTranslations("lessonPreparation")
|
||||
const [questions, setQuestions] = useState<QuestionRow[]>([])
|
||||
const [picked, setPicked] = useState<ExerciseItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// QuestionBankFilters 使用字符串值,这里转换为 filters 对象
|
||||
const [searchValue, setSearchValue] = useState("")
|
||||
@@ -53,20 +55,43 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
||||
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])
|
||||
let cancelled = false
|
||||
// 使用 Promise.resolve().then() 避免在 effect 中同步调用 setState
|
||||
Promise.resolve()
|
||||
.then(() => {
|
||||
if (cancelled) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
return getQuestionsAction(debouncedFilters)
|
||||
})
|
||||
.then((res) => {
|
||||
if (cancelled || !res) return
|
||||
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,
|
||||
})),
|
||||
)
|
||||
} else {
|
||||
setError(res.message ?? t("error.loadFailed"))
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (cancelled) return
|
||||
console.error("[QuestionBankPicker] load questions failed", e)
|
||||
setError(t("error.loadFailed"))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [debouncedFilters, t])
|
||||
|
||||
function add(q: QuestionRow) {
|
||||
if (existingIds.includes(q.id) || picked.some((p) => p.questionId === q.id)) return
|
||||
@@ -116,22 +141,34 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
||||
/>
|
||||
</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">
|
||||
{t(`questionBank.type.${q.type}`)} · {t("questionBank.difficulty", { level: q.difficulty })}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={() => add(q)}>
|
||||
{t("questionBank.add")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="text-on-surface-variant text-sm text-center py-8">
|
||||
{t("questionBank.loading")}
|
||||
</p>
|
||||
) : error ? (
|
||||
<p className="text-error text-sm text-center py-8">{error}</p>
|
||||
) : questions.length === 0 ? (
|
||||
<p className="text-on-surface-variant text-sm text-center py-8">
|
||||
{t("questionBank.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<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">
|
||||
{t(`questionBank.type.${q.type}`)} · {t("questionBank.difficulty", { level: q.difficulty })}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={() => add(q)}>
|
||||
{t("questionBank.add")}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 border-t flex justify-between">
|
||||
<span className="text-sm">{t("questionBank.selected", { count: picked.length })}</span>
|
||||
|
||||
@@ -1,36 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { createLessonPlanAction } from "../actions";
|
||||
import { createLessonPlanAction, getTextbooksForPickerAction, getChaptersForPickerAction } from "../actions";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { SYSTEM_TEMPLATES } from "../constants";
|
||||
import { useLessonPlanTrackerSafe } from "../providers/lesson-plan-provider";
|
||||
import { Book, ChevronRight, FileText, Loader2 } from "lucide-react";
|
||||
|
||||
interface TextbookOption {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string | null;
|
||||
}
|
||||
|
||||
interface ChapterOption {
|
||||
id: string;
|
||||
title: string;
|
||||
parentId: string | null;
|
||||
order: number | null;
|
||||
content?: string | null;
|
||||
children?: unknown[];
|
||||
}
|
||||
|
||||
export function TemplatePicker() {
|
||||
const t = useTranslations("lessonPreparation");
|
||||
const router = useRouter();
|
||||
const tracker = useLessonPlanTrackerSafe();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [textbooks, setTextbooks] = useState<TextbookOption[]>([]);
|
||||
const [textbookId, setTextbookId] = useState<string>("");
|
||||
const [chapters, setChapters] = useState<ChapterOption[]>([]);
|
||||
const [chapterId, setChapterId] = useState<string>(
|
||||
() => searchParams.get("chapterId") ?? "",
|
||||
);
|
||||
const [loadedTextbookId, setLoadedTextbookId] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string>("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingTextbooks, setLoadingTextbooks] = useState(true);
|
||||
|
||||
// 派生:当前教材的章节是否正在加载
|
||||
const loadingChapters = !!textbookId && textbookId !== loadedTextbookId;
|
||||
|
||||
// 初始加载教材列表 + URL 参数预选
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getTextbooksForPickerAction()
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.success && res.data) {
|
||||
setTextbooks(res.data.textbooks);
|
||||
// URL 参数预选
|
||||
const urlTextbookId = searchParams.get("textbookId");
|
||||
if (urlTextbookId && res.data.textbooks.some((tb) => tb.id === urlTextbookId)) {
|
||||
setTextbookId(urlTextbookId);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("[TemplatePicker] load textbooks failed", e);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingTextbooks(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [searchParams]);
|
||||
|
||||
// 教材变化时加载章节
|
||||
useEffect(() => {
|
||||
if (!textbookId) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
getChaptersForPickerAction(textbookId)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.success && res.data) {
|
||||
setChapters(res.data.chapters);
|
||||
setLoadedTextbookId(textbookId);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("[TemplatePicker] load chapters failed", e);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [textbookId]);
|
||||
|
||||
// 扁平化章节列表(用于下拉选择,带缩进前缀)
|
||||
const flattenedChapters = useMemo(() => {
|
||||
const result: { id: string; title: string; depth: number }[] = [];
|
||||
function walk(list: ChapterOption[], depth: number) {
|
||||
for (const ch of list) {
|
||||
result.push({ id: ch.id, title: ch.title, depth });
|
||||
if (ch.children && Array.isArray(ch.children) && ch.children.length > 0) {
|
||||
walk(ch.children as ChapterOption[], depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(chapters, 0);
|
||||
return result;
|
||||
}, [chapters]);
|
||||
|
||||
// 选中章节时自动填充标题(如果标题为空)
|
||||
const selectedChapter = useMemo(
|
||||
() => flattenedChapters.find((c) => c.id === chapterId),
|
||||
[flattenedChapters, chapterId],
|
||||
);
|
||||
|
||||
const handleChapterChange = useCallback((id: string) => {
|
||||
setChapterId(id);
|
||||
// 如果标题为空,用章节标题预填
|
||||
const ch = flattenedChapters.find((c) => c.id === id);
|
||||
if (ch && !title) {
|
||||
setTitle(ch.title);
|
||||
}
|
||||
}, [flattenedChapters, title]);
|
||||
|
||||
const selectedTextbook = textbooks.find((tb) => tb.id === textbookId);
|
||||
|
||||
const canSubmit = !!selected && !!title && !!textbookId && !!chapterId;
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
setError(null);
|
||||
if (!textbookId || !chapterId) {
|
||||
setError(t("picker.errorTextbookChapterRequired"));
|
||||
return;
|
||||
}
|
||||
formData.set("templateId", selected);
|
||||
formData.set("title", title);
|
||||
const res = await createLessonPlanAction(null, formData);
|
||||
if (res.success && res.data) {
|
||||
tracker.track("lesson_plan.create", { planId: res.data.planId, templateId: selected });
|
||||
router.push(`/teacher/lesson-plans/${res.data.planId}/edit`);
|
||||
} else {
|
||||
setError(res.message ?? t("error.createFailed"));
|
||||
formData.set("textbookId", textbookId);
|
||||
formData.set("chapterId", chapterId);
|
||||
try {
|
||||
const res = await createLessonPlanAction(null, formData);
|
||||
if (res.success && res.data) {
|
||||
tracker.track("lesson_plan.create", { planId: res.data.planId, templateId: selected });
|
||||
router.push(`/teacher/lesson-plans/${res.data.planId}/edit`);
|
||||
} else {
|
||||
setError(res.message ?? t("error.createFailed"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[TemplatePicker] create failed", e);
|
||||
setError(t("error.createFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={handleSubmit} className="max-w-3xl mx-auto p-6 space-y-6">
|
||||
{/* 步骤 1:选择教材 */}
|
||||
<div>
|
||||
<label className="font-title-md block mb-2 flex items-center gap-2">
|
||||
<Book className="w-4 h-4" />
|
||||
{t("picker.textbookLabel")}
|
||||
<span className="text-error text-sm">*</span>
|
||||
</label>
|
||||
{loadingTextbooks ? (
|
||||
<div className="flex items-center gap-2 text-on-surface-variant text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t("picker.loadingTextbooks")}
|
||||
</div>
|
||||
) : textbooks.length === 0 ? (
|
||||
<p className="text-on-surface-variant text-sm">{t("picker.noTextbooks")}</p>
|
||||
) : (
|
||||
<select
|
||||
value={textbookId}
|
||||
onChange={(e) => {
|
||||
setTextbookId(e.target.value);
|
||||
setChapterId("");
|
||||
}}
|
||||
required
|
||||
className="w-full border border-outline-variant rounded-lg px-3 py-2 bg-surface"
|
||||
>
|
||||
<option value="">{t("picker.selectTextbook")}</option>
|
||||
{textbooks.map((tb) => (
|
||||
<option key={tb.id} value={tb.id}>
|
||||
{tb.title}
|
||||
{tb.subject ? ` · ${tb.subject}` : ""}
|
||||
{tb.grade ? ` · ${tb.grade}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 步骤 2:选择章节 */}
|
||||
<div>
|
||||
<label className="font-title-md block mb-2 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
{t("picker.chapterLabel")}
|
||||
<span className="text-error text-sm">*</span>
|
||||
</label>
|
||||
{!textbookId ? (
|
||||
<p className="text-on-surface-variant text-sm">{t("picker.selectTextbookFirst")}</p>
|
||||
) : loadingChapters ? (
|
||||
<div className="flex items-center gap-2 text-on-surface-variant text-sm">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t("picker.loadingChapters")}
|
||||
</div>
|
||||
) : flattenedChapters.length === 0 ? (
|
||||
<p className="text-on-surface-variant text-sm">{t("picker.noChapters")}</p>
|
||||
) : (
|
||||
<select
|
||||
value={chapterId}
|
||||
onChange={(e) => handleChapterChange(e.target.value)}
|
||||
required
|
||||
className="w-full border border-outline-variant rounded-lg px-3 py-2 bg-surface"
|
||||
>
|
||||
<option value="">{t("picker.selectChapter")}</option>
|
||||
{flattenedChapters.map((ch) => (
|
||||
<option key={ch.id} value={ch.id}>
|
||||
{" ".repeat(ch.depth)}
|
||||
{ch.depth > 0 ? "└ " : ""}
|
||||
{ch.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{selectedChapter && (
|
||||
<p className="text-xs text-on-surface-variant mt-1 flex items-center gap-1">
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
{t("picker.selectedChapter", { chapter: selectedChapter.title })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 步骤 3:标题 */}
|
||||
<div>
|
||||
<label className="font-title-md block mb-2">{t("template.titleLabel")}</label>
|
||||
<input
|
||||
@@ -41,6 +243,8 @@ export function TemplatePicker() {
|
||||
placeholder={t("template.titlePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 步骤 4:模板 */}
|
||||
<div>
|
||||
<label className="font-title-md block mb-2">{t("template.selectLabel")}</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
@@ -64,9 +268,15 @@ export function TemplatePicker() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedTextbook && selectedChapter && (
|
||||
<p className="text-xs text-on-surface-variant mt-2">
|
||||
{t("picker.skeletonHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-error text-sm">{error}</p>}
|
||||
<Button type="submit" disabled={!selected || !title}>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{t("action.create")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -48,11 +48,17 @@ export function VersionHistoryDrawer({
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(true);
|
||||
getLessonPlanVersionsAction(planId).then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.success && res.data) setVersions(res.data.versions);
|
||||
setLoading(false);
|
||||
});
|
||||
getLessonPlanVersionsAction(planId)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.success && res.data) setVersions(res.data.versions);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("[VersionHistoryDrawer] load versions failed", e);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -60,14 +66,19 @@ export function VersionHistoryDrawer({
|
||||
}, [open, planId]);
|
||||
|
||||
async function handleRevert(versionNo: number) {
|
||||
const res = await revertLessonPlanVersionAction({ planId, versionNo });
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.revert", { planId, versionNo });
|
||||
toast.success(t("version.revertSuccess", { versionNo }));
|
||||
onReverted();
|
||||
onClose();
|
||||
} else {
|
||||
toast.error(res.message ?? t("error.revert"));
|
||||
try {
|
||||
const res = await revertLessonPlanVersionAction({ planId, versionNo });
|
||||
if (res.success) {
|
||||
tracker.track("lesson_plan.revert", { planId, versionNo });
|
||||
toast.success(t("version.revertSuccess", { versionNo }));
|
||||
onReverted();
|
||||
onClose();
|
||||
} else {
|
||||
toast.error(res.message ?? t("error.revert"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[VersionHistoryDrawer] revert failed", e);
|
||||
toast.error(t("error.revert"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user