feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
主要变更: - 新增 lesson-preparation 模块: 备课编辑器、节点编辑、AI 建议、知识点选择、版本历史、作业发布 - 新增 shared 通用组件: charts/question-bank-filters/schedule-list/ui (chip-nav/filter-bar/page-header/stat-card/stat-item) - 新增 student/admin 端 loading.tsx 与 error.tsx, 优化加载与错误态体验 - 新增 teacher/lesson-plans 页面 (列表/新建/编辑) - 新增 drizzle 迁移 0002_tiny_lionheart 及 snapshot - 新增 textbooks/schema.ts 与 exams/utils/normalize-structure.ts - 修复 Tiptap v3 SSR hydration 崩溃 (rich-text-block immediatelyRender: false) - 重构多模块 data-access/actions/组件, 修复权限校验与类型规范 - 同步架构文档 004/005 反映新增模块、导出、依赖关系 - 归档 bugs/* 测试报告与 e2e 测试脚本 (admin/parent/student/teacher web_test)
This commit is contained in:
181
src/modules/lesson-preparation/components/block-renderer.tsx
Normal file
181
src/modules/lesson-preparation/components/block-renderer.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* @deprecated 已被 NodeEditor 替代,保留此文件用于向后兼容。
|
||||
* 列表式渲染器,使用新的 nodes API。
|
||||
*/
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
GripVertical,
|
||||
Trash2,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||||
import { RICH_TEXT_BLOCK_TYPES } from "../constants";
|
||||
import { RichTextBlock } from "./blocks/rich-text-block";
|
||||
import { ExerciseBlock } from "./blocks/exercise-block";
|
||||
import { TextStudyBlock } from "./blocks/text-study-block";
|
||||
import { ReflectionBlock } from "./blocks/reflection-block";
|
||||
import type { LessonPlanNode, RichTextBlockData } from "../types";
|
||||
|
||||
interface BlockRendererProps {
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
classes?: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
function SortableBlock({
|
||||
node,
|
||||
index,
|
||||
total,
|
||||
textbookId,
|
||||
chapterId,
|
||||
classes,
|
||||
}: {
|
||||
node: LessonPlanNode;
|
||||
index: number;
|
||||
total: number;
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
classes?: { id: string; name: string }[];
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: node.id });
|
||||
const { updateNode, removeNode } = useLessonPlanEditor();
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const isRichText = RICH_TEXT_BLOCK_TYPES.includes(node.type);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="border border-outline-variant rounded-lg bg-surface-container-lowest"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-outline-variant bg-surface-container-low">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing text-outline hover:text-on-surface"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</button>
|
||||
<input
|
||||
value={node.title}
|
||||
onChange={(e) => updateNode(node.id, { title: e.target.value })}
|
||||
className="flex-1 bg-transparent font-title-md text-title-md focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateNode(node.id, { order: index - 1 })}
|
||||
disabled={index === 0}
|
||||
className="p-1 text-outline hover:text-on-surface disabled:opacity-30"
|
||||
>
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateNode(node.id, { order: index + 1 })}
|
||||
disabled={index === total - 1}
|
||||
className="p-1 text-outline hover:text-on-surface disabled:opacity-30"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeNode(node.id)}
|
||||
className="p-1 text-error hover:text-error/80"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
{isRichText ? (
|
||||
<RichTextBlock
|
||||
data={node.data as RichTextBlockData}
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
onUpdate={(d) => updateNode(node.id, { data: d })}
|
||||
/>
|
||||
) : node.type === "exercise" ? (
|
||||
<ExerciseBlock
|
||||
blockId={node.id}
|
||||
data={node.data as never}
|
||||
classes={classes ?? []}
|
||||
/>
|
||||
) : node.type === "text_study" ? (
|
||||
<TextStudyBlock
|
||||
blockId={node.id}
|
||||
data={node.data as never}
|
||||
/>
|
||||
) : node.type === "reflection" ? (
|
||||
<ReflectionBlock
|
||||
data={node.data as RichTextBlockData}
|
||||
onUpdate={(d) => updateNode(node.id, { data: d })}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-on-surface-variant text-sm p-4">
|
||||
未知 block 类型
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BlockRenderer({
|
||||
textbookId,
|
||||
chapterId,
|
||||
classes,
|
||||
}: BlockRendererProps) {
|
||||
const { doc } = useLessonPlanEditor();
|
||||
|
||||
function onDragEnd(e: DragEndEvent) {
|
||||
const { active, over } = e;
|
||||
if (!over || active.id === over.id) return;
|
||||
// 拖拽排序仅更新 order 字段,实际位置由节点图管理
|
||||
const oldIndex = doc.nodes.findIndex((b) => b.id === active.id);
|
||||
const newIndex = doc.nodes.findIndex((b) => b.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
// 交换 order
|
||||
const nodes = [...doc.nodes];
|
||||
const tmpOrder = nodes[oldIndex].order;
|
||||
nodes[oldIndex].order = nodes[newIndex].order;
|
||||
nodes[newIndex].order = tmpOrder;
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext
|
||||
items={doc.nodes.map((b) => b.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{doc.nodes.map((b, i) => (
|
||||
<SortableBlock
|
||||
key={b.id}
|
||||
node={b}
|
||||
index={i}
|
||||
total={doc.nodes.length}
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
classes={classes}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||||
import { QuestionBankPicker } from "../question-bank-picker";
|
||||
import { InlineQuestionEditor } from "../inline-question-editor";
|
||||
import { PublishHomeworkDialog } from "../publish-homework-dialog";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import type {
|
||||
ExerciseBlockData,
|
||||
ExerciseItem,
|
||||
} from "../../types";
|
||||
|
||||
interface Props {
|
||||
blockId: string;
|
||||
data: ExerciseBlockData;
|
||||
classes: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function ExerciseBlock({ blockId, data, classes }: Props) {
|
||||
const { updateNode, planId } = useLessonPlanEditor();
|
||||
const [showBank, setShowBank] = useState(false);
|
||||
const [showInline, setShowInline] = useState(false);
|
||||
const [showPublish, setShowPublish] = useState(false);
|
||||
|
||||
function update(patch: Partial<ExerciseBlockData>) {
|
||||
updateNode(blockId, { data: { ...data, ...patch } });
|
||||
}
|
||||
|
||||
function addItems(items: ExerciseItem[]) {
|
||||
const next = [...data.items, ...items];
|
||||
update({
|
||||
items: next.map((it, i) => ({ ...it, order: i })),
|
||||
});
|
||||
}
|
||||
|
||||
function removeItem(idx: number) {
|
||||
update({
|
||||
items: data.items
|
||||
.filter((_, i) => i !== idx)
|
||||
.map((it, i) => ({ ...it, order: i })),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={data.purpose}
|
||||
onChange={(e) =>
|
||||
update({ purpose: e.target.value as never })
|
||||
}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="class_practice">课堂练习</option>
|
||||
<option value="after_class_homework">课后作业</option>
|
||||
</select>
|
||||
</div>
|
||||
{data.items.length === 0 ? (
|
||||
<p className="text-on-surface-variant text-sm p-4 text-center border border-dashed rounded">
|
||||
暂无题目,点击下方按钮添加
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{data.items.map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-2 border rounded p-2"
|
||||
>
|
||||
<span className="text-xs bg-surface-container-highest px-2 py-0.5 rounded">
|
||||
{item.source === "bank" ? "题库" : "新建"}
|
||||
</span>
|
||||
<span className="text-sm flex-1 truncate">
|
||||
{item.source === "bank"
|
||||
? `题目 ${item.questionId.slice(0, 8)}`
|
||||
: "课案内新建题目"}
|
||||
</span>
|
||||
<span className="text-xs">{item.score}分</span>
|
||||
<button onClick={() => removeItem(idx)}>
|
||||
<Trash2 className="w-3 h-3 text-error" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowBank(true)}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
从题库添加
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowInline(true)}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
新建题目
|
||||
</Button>
|
||||
{data.publishedAssignmentId ? (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="bg-tertiary-container/20 text-tertiary px-2 py-1 rounded">
|
||||
已发布为作业
|
||||
</span>
|
||||
<a
|
||||
href="/teacher/homework"
|
||||
className="text-primary underline"
|
||||
>
|
||||
查看
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
data.purpose === "after_class_homework" &&
|
||||
data.items.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowPublish(true)}
|
||||
>
|
||||
发布为作业
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{showBank && (
|
||||
<QuestionBankPicker
|
||||
existingIds={data.items.map((i) => i.questionId)}
|
||||
onPick={addItems}
|
||||
onClose={() => setShowBank(false)}
|
||||
/>
|
||||
)}
|
||||
{showInline && (
|
||||
<InlineQuestionEditor
|
||||
onAdd={(item) => {
|
||||
addItems([item]);
|
||||
setShowInline(false);
|
||||
}}
|
||||
onClose={() => setShowInline(false)}
|
||||
/>
|
||||
)}
|
||||
{showPublish && (
|
||||
<PublishHomeworkDialog
|
||||
planId={planId}
|
||||
blockId={blockId}
|
||||
classes={classes}
|
||||
onClose={() => setShowPublish(false)}
|
||||
onPublished={() => window.location.reload()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { RichTextBlock } from "./rich-text-block";
|
||||
import type { RichTextBlockData } from "../../types";
|
||||
|
||||
interface Props {
|
||||
data: RichTextBlockData;
|
||||
onUpdate: (data: RichTextBlockData) => void;
|
||||
}
|
||||
|
||||
export function ReflectionBlock(props: Props) {
|
||||
// 教学反思在 P1 阶段与普通富文本一致,P3 再扩展学情数据嵌入
|
||||
return <RichTextBlock {...props} hint="课后填写教学反思..." />;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { RichTextBlockData } from "../../types";
|
||||
import { KnowledgePointPicker } from "../knowledge-point-picker";
|
||||
import { Tag } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
data: RichTextBlockData;
|
||||
hint?: string;
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
onUpdate: (data: RichTextBlockData) => void;
|
||||
}
|
||||
|
||||
export function RichTextBlock({
|
||||
data,
|
||||
hint,
|
||||
textbookId,
|
||||
chapterId,
|
||||
onUpdate,
|
||||
}: Props) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Placeholder.configure({ placeholder: hint ?? "输入内容..." }),
|
||||
],
|
||||
content: data.html,
|
||||
immediatelyRender: false,
|
||||
onUpdate: ({ editor }) => {
|
||||
onUpdate({ ...data, html: editor.getHTML() });
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"prose prose-sm max-w-none focus:outline-none min-h-[60px] px-3 py-2",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 外部 content 变化时同步(如版本回退)
|
||||
useEffect(() => {
|
||||
if (editor && !editor.isDestroyed && data.html !== editor.getHTML()) {
|
||||
editor.commands.setContent(data.html);
|
||||
}
|
||||
}, [data.html, editor]);
|
||||
|
||||
const [showKpPicker, setShowKpPicker] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EditorContent editor={editor} />
|
||||
<div className="flex items-center gap-2 mt-2 px-3 flex-wrap">
|
||||
{data.knowledgePointIds.length > 0 && (
|
||||
<span className="text-xs text-on-surface-variant">
|
||||
已关联 {data.knowledgePointIds.length} 个知识点
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowKpPicker(true)}
|
||||
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<Tag className="w-3 h-3" />
|
||||
标注知识点
|
||||
</button>
|
||||
</div>
|
||||
{showKpPicker && (
|
||||
<KnowledgePointPicker
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
selectedIds={data.knowledgePointIds}
|
||||
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
|
||||
onClose={() => setShowKpPicker(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import type {
|
||||
TextStudyBlockData,
|
||||
TextStudyAnnotation,
|
||||
} from "../../types";
|
||||
|
||||
interface Props {
|
||||
blockId: string;
|
||||
data: TextStudyBlockData;
|
||||
}
|
||||
|
||||
export function TextStudyBlock({ blockId, data }: Props) {
|
||||
const { updateNode } = useLessonPlanEditor();
|
||||
const [selection, setSelection] = useState<{
|
||||
start: number;
|
||||
end: number;
|
||||
} | null>(null);
|
||||
|
||||
function update(patch: Partial<TextStudyBlockData>) {
|
||||
updateNode(blockId, { data: { ...data, ...patch } });
|
||||
}
|
||||
|
||||
function handleTextSelect() {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return;
|
||||
const range = sel.getRangeAt(0);
|
||||
// 简化:用相对 sourceText 的字符偏移
|
||||
const start = range.startOffset;
|
||||
const end = range.endOffset;
|
||||
if (end > start) setSelection({ start, end });
|
||||
}
|
||||
|
||||
function addAnnotation() {
|
||||
if (!selection) {
|
||||
alert("请先在课文中选中一段文本");
|
||||
return;
|
||||
}
|
||||
const ann: TextStudyAnnotation = {
|
||||
id: createId(),
|
||||
anchor: selection,
|
||||
nodeType: "language_feature",
|
||||
title: "教学节点",
|
||||
note: "",
|
||||
color: "yellow",
|
||||
};
|
||||
update({ annotations: [...data.annotations, ann] });
|
||||
setSelection(null);
|
||||
}
|
||||
|
||||
function removeAnnotation(id: string) {
|
||||
update({
|
||||
annotations: data.annotations.filter((a) => a.id !== id),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">课文原文</label>
|
||||
<textarea
|
||||
value={data.sourceText}
|
||||
onChange={(e) => update({ sourceText: e.target.value })}
|
||||
onMouseUp={handleTextSelect}
|
||||
className="w-full border rounded p-2 mt-1 min-h-[120px] font-serif leading-loose"
|
||||
placeholder="粘贴课文原文,选中文本后可添加教学节点"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addAnnotation}
|
||||
disabled={!selection}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
为选中文本添加节点
|
||||
</Button>
|
||||
{data.annotations.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{data.annotations.map((ann) => (
|
||||
<div
|
||||
key={ann.id}
|
||||
className="border-l-4 border-secondary-container pl-3 py-1"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<input
|
||||
value={ann.title}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
annotations: data.annotations.map((a) =>
|
||||
a.id === ann.id
|
||||
? { ...a, title: e.target.value }
|
||||
: a,
|
||||
),
|
||||
})
|
||||
}
|
||||
className="font-medium text-sm bg-transparent flex-1"
|
||||
/>
|
||||
<button onClick={() => removeAnnotation(ann.id)}>
|
||||
<Trash2 className="w-3 h-3 text-error" />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={ann.note}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
annotations: data.annotations.map((a) =>
|
||||
a.id === ann.id
|
||||
? { ...a, note: e.target.value }
|
||||
: a,
|
||||
),
|
||||
})
|
||||
}
|
||||
className="w-full text-sm border rounded p-1 mt-1 min-h-[40px]"
|
||||
placeholder="教学说明..."
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { X } from "lucide-react";
|
||||
import type { ExerciseItem, InlineQuestionContent } from "../types";
|
||||
|
||||
interface Props {
|
||||
onAdd: (item: ExerciseItem) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function InlineQuestionEditor({ onAdd, onClose }: Props) {
|
||||
const [type, setType] = useState<
|
||||
"single_choice" | "text" | "judgment"
|
||||
>("single_choice");
|
||||
const [difficulty, setDifficulty] = useState(3);
|
||||
const [text, setText] = useState("");
|
||||
const [options, setOptions] = useState<string[]>(["", ""]);
|
||||
const [correctIdx, setCorrectIdx] = useState(0);
|
||||
const kpIds: string[] = [];
|
||||
|
||||
function handleAdd() {
|
||||
if (!text.trim()) {
|
||||
alert("请输入题干");
|
||||
return;
|
||||
}
|
||||
const content: Record<string, unknown> =
|
||||
type === "single_choice"
|
||||
? {
|
||||
text,
|
||||
options: options.map((o, i) => ({
|
||||
id: String(i),
|
||||
text: o,
|
||||
isCorrect: i === correctIdx,
|
||||
})),
|
||||
}
|
||||
: type === "judgment"
|
||||
? { text, correctAnswer: correctIdx === 0 }
|
||||
: { text };
|
||||
const inlineContent: InlineQuestionContent = {
|
||||
content,
|
||||
type,
|
||||
difficulty,
|
||||
knowledgePointIds: kpIds,
|
||||
};
|
||||
const item: ExerciseItem = {
|
||||
questionId: `inline_draft_${createId()}`,
|
||||
source: "inline",
|
||||
score: 5,
|
||||
order: 0,
|
||||
inlineContent,
|
||||
};
|
||||
onAdd(item);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-[600px] max-h-[80vh] flex flex-col">
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="font-title-md">新建题目(课案内)</h3>
|
||||
<button onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">题型</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as never)}
|
||||
className="w-full border rounded px-2 py-1 mt-1"
|
||||
>
|
||||
<option value="single_choice">单选题</option>
|
||||
<option value="text">填空题</option>
|
||||
<option value="judgment">判断题</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">题干</label>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className="w-full border rounded px-2 py-1 mt-1 min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
{type === "single_choice" && (
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
选项(勾选正确答案)
|
||||
</label>
|
||||
{options.map((opt, i) => (
|
||||
<div key={i} className="flex items-center gap-2 mt-1">
|
||||
<input
|
||||
type="radio"
|
||||
checked={correctIdx === i}
|
||||
onChange={() => setCorrectIdx(i)}
|
||||
/>
|
||||
<input
|
||||
value={opt}
|
||||
onChange={(e) =>
|
||||
setOptions(
|
||||
options.map((o, j) =>
|
||||
j === i ? e.target.value : o,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="flex-1 border rounded px-2 py-1"
|
||||
/>
|
||||
{options.length > 2 && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setOptions(options.filter((_, j) => j !== i))
|
||||
}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{options.length < 6 && (
|
||||
<button
|
||||
onClick={() => setOptions([...options, ""])}
|
||||
className="text-sm text-primary mt-1"
|
||||
>
|
||||
+ 添加选项
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{type === "judgment" && (
|
||||
<div>
|
||||
<label className="text-sm font-medium">正确答案</label>
|
||||
<div className="flex gap-3 mt-1">
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
checked={correctIdx === 0}
|
||||
onChange={() => setCorrectIdx(0)}
|
||||
/>
|
||||
正确
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
checked={correctIdx === 1}
|
||||
onChange={() => setCorrectIdx(1)}
|
||||
/>
|
||||
错误
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-sm font-medium">难度</label>
|
||||
<select
|
||||
value={difficulty}
|
||||
onChange={(e) => setDifficulty(Number(e.target.value))}
|
||||
className="w-full border rounded px-2 py-1 mt-1"
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}星
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleAdd}>添加</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { X } from "lucide-react";
|
||||
import { getKnowledgePointOptionsAction } from "../actions-kp";
|
||||
|
||||
interface KpOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
selectedIds: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function KnowledgePointPicker({
|
||||
textbookId,
|
||||
chapterId,
|
||||
selectedIds,
|
||||
onChange,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [options, setOptions] = useState<KpOption[]>([]);
|
||||
const [local, setLocal] = useState<string[]>(selectedIds);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textbookId) {
|
||||
return;
|
||||
}
|
||||
getKnowledgePointOptionsAction({ textbookId, chapterId }).then((res) => {
|
||||
if (res.success && res.data) setOptions(res.data.options);
|
||||
});
|
||||
}, [textbookId, chapterId]);
|
||||
|
||||
function toggle(id: string) {
|
||||
setLocal((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-96 max-h-[70vh] flex flex-col">
|
||||
<div className="flex justify-between items-center p-4 border-b border-outline-variant">
|
||||
<h3 className="font-title-md">选择知识点</h3>
|
||||
<button onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{options.length === 0 ? (
|
||||
<p className="text-on-surface-variant text-sm">
|
||||
未找到知识点,请先在教材模块创建
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{options.map((kp) => (
|
||||
<label
|
||||
key={kp.id}
|
||||
className="flex items-center gap-2 p-2 hover:bg-surface-container-highest rounded cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={local.includes(kp.id)}
|
||||
onChange={() => toggle(kp.id)}
|
||||
/>
|
||||
<span className="text-sm">{kp.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 border-t border-outline-variant flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onChange(local);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { LESSON_PLAN_STATUS_LABELS } from "../constants";
|
||||
import { duplicateLessonPlanAction, deleteLessonPlanAction } from "../actions";
|
||||
import type { LessonPlanListItem } from "../types";
|
||||
|
||||
export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
||||
return (
|
||||
<div className="border border-outline-variant rounded-lg p-4 bg-surface-container-lowest hover:shadow-md transition-shadow">
|
||||
<Link
|
||||
href={`/teacher/lesson-plans/${plan.id}/edit`}
|
||||
className="block"
|
||||
>
|
||||
<h3 className="font-title-md text-title-md hover:text-primary">
|
||||
{plan.title}
|
||||
</h3>
|
||||
</Link>
|
||||
<div className="text-sm text-on-surface-variant mt-1">
|
||||
{plan.textbookTitle ?? "无教材"} · {plan.chapterTitle ?? "无章节"}
|
||||
</div>
|
||||
<div className="text-xs text-on-surface-variant mt-1">
|
||||
{plan.templateName ?? "无模板"} ·{" "}
|
||||
{LESSON_PLAN_STATUS_LABELS[plan.status]}
|
||||
</div>
|
||||
<div className="text-xs text-on-surface-variant mt-2">
|
||||
最后保存:
|
||||
{plan.lastSavedAt
|
||||
? new Date(plan.lastSavedAt).toLocaleString()
|
||||
: "未保存"}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const res = await duplicateLessonPlanAction(plan.id);
|
||||
if (res.success) window.location.reload();
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (!confirm("确认归档此课案?")) return;
|
||||
const res = await deleteLessonPlanAction(plan.id);
|
||||
if (res.success) window.location.reload();
|
||||
}}
|
||||
>
|
||||
归档
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
src/modules/lesson-preparation/components/lesson-plan-editor.tsx
Normal file
234
src/modules/lesson-preparation/components/lesson-plan-editor.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||||
import { NodeEditor } from "./node-editor";
|
||||
import { NodeEditPanel } from "./node-edit-panel";
|
||||
import { VersionHistoryDrawer } from "./version-history-drawer";
|
||||
import {
|
||||
updateLessonPlanAction,
|
||||
saveLessonPlanVersionAction,
|
||||
getLessonPlanByIdAction,
|
||||
} from "../actions";
|
||||
import { BLOCK_TYPE_LABELS } from "../constants";
|
||||
import type { BlockType } from "../types";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Plus, Save, History } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
planId: string;
|
||||
initialTitle: string;
|
||||
initialDoc: import("../types").LessonPlanDocument;
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
classes?: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
const BLOCK_TYPES_TO_ADD: BlockType[] = [
|
||||
"objective",
|
||||
"key_point",
|
||||
"import",
|
||||
"new_teaching",
|
||||
"consolidation",
|
||||
"summary",
|
||||
"homework",
|
||||
"blackboard",
|
||||
"exercise",
|
||||
"text_study",
|
||||
"rich_text",
|
||||
"reflection",
|
||||
];
|
||||
|
||||
export function LessonPlanEditor({
|
||||
planId,
|
||||
initialTitle,
|
||||
initialDoc,
|
||||
textbookId,
|
||||
chapterId,
|
||||
classes,
|
||||
}: Props) {
|
||||
const editor = useLessonPlanEditor();
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const [showAddMenu, setShowAddMenu] = useState(false);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const versionTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const addMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 初始化:仅在 planId 变化时 hydrate(修复 P1-3)
|
||||
const initKey = planId;
|
||||
useEffect(() => {
|
||||
useLessonPlanEditor.getState().hydrate(planId, initialTitle, initialDoc);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initKey]);
|
||||
|
||||
// 选中节点时打开侧边面板
|
||||
useEffect(() => {
|
||||
if (editor.selectedNodeId) setPanelOpen(true);
|
||||
}, [editor.selectedNodeId]);
|
||||
|
||||
// 自动保存(debounce 3s)- 用 getState() 获取最新值(修复 P1-4)
|
||||
useEffect(() => {
|
||||
if (!editor.isDirty) return;
|
||||
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
||||
autoSaveTimer.current = setTimeout(async () => {
|
||||
const state = useLessonPlanEditor.getState();
|
||||
state.setSaving(true);
|
||||
const res = await updateLessonPlanAction({
|
||||
planId: state.planId,
|
||||
title: state.title,
|
||||
content: state.doc,
|
||||
});
|
||||
state.setSaving(false);
|
||||
if (res.success) state.markSaved();
|
||||
}, 3000);
|
||||
return () => {
|
||||
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
||||
};
|
||||
}, [editor.isDirty, editor.doc, planId]);
|
||||
|
||||
// 定时自动版本(30min)
|
||||
useEffect(() => {
|
||||
versionTimer.current = setInterval(async () => {
|
||||
const state = useLessonPlanEditor.getState();
|
||||
if (!state.isDirty) return;
|
||||
await saveLessonPlanVersionAction({
|
||||
planId: state.planId,
|
||||
content: state.doc,
|
||||
label: "自动版本",
|
||||
});
|
||||
}, 30 * 60 * 1000);
|
||||
return () => {
|
||||
if (versionTimer.current) clearInterval(versionTimer.current);
|
||||
};
|
||||
}, [planId]);
|
||||
|
||||
// 离开未保存提示(P3-1)
|
||||
useEffect(() => {
|
||||
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (useLessonPlanEditor.getState().isDirty) {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
}
|
||||
}
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, []);
|
||||
|
||||
// 添加节点菜单点击外部关闭(P3-2)
|
||||
useEffect(() => {
|
||||
if (!showAddMenu) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (addMenuRef.current && !addMenuRef.current.contains(e.target as Node)) {
|
||||
setShowAddMenu(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [showAddMenu]);
|
||||
|
||||
const handleManualSave = useCallback(async () => {
|
||||
const state = useLessonPlanEditor.getState();
|
||||
state.setSaving(true);
|
||||
const res = await saveLessonPlanVersionAction({
|
||||
planId: state.planId,
|
||||
content: state.doc,
|
||||
});
|
||||
state.setSaving(false);
|
||||
if (res.success) state.markSaved();
|
||||
}, []);
|
||||
|
||||
// 版本回退后刷新内容(修复 P1-1)
|
||||
const handleReverted = useCallback(async () => {
|
||||
const state = useLessonPlanEditor.getState();
|
||||
const res = await getLessonPlanByIdAction(state.planId);
|
||||
if (res.success && res.data?.plan) {
|
||||
state.hydrate(state.planId, res.data.plan.title, res.data.plan.content);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant bg-surface">
|
||||
<input
|
||||
value={editor.title}
|
||||
onChange={(e) => editor.setTitle(e.target.value)}
|
||||
className="flex-1 bg-transparent font-headline-md text-headline-md focus:outline-none"
|
||||
/>
|
||||
<span className="text-on-surface-variant text-sm">
|
||||
{editor.isSaving
|
||||
? "保存中..."
|
||||
: editor.isDirty
|
||||
? "未保存"
|
||||
: "已保存"}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowVersions(true)}
|
||||
>
|
||||
<History className="w-4 h-4 mr-1" /> 版本
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleManualSave} disabled={editor.isSaving}>
|
||||
<Save className="w-4 h-4 mr-1" /> 保存版本
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 主区域:画布 + 侧边面板 */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* 节点画布 */}
|
||||
<div className="flex-1 relative">
|
||||
<NodeEditor
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
classes={classes}
|
||||
/>
|
||||
{/* 添加节点浮动按钮 */}
|
||||
<div className="absolute bottom-4 left-4 z-10" ref={addMenuRef}>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setShowAddMenu(!showAddMenu)}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" /> 添加节点
|
||||
</Button>
|
||||
{showAddMenu && (
|
||||
<div className="absolute bottom-12 left-0 bg-surface border border-outline-variant rounded-lg shadow-lg p-2 grid grid-cols-2 gap-1 w-72 max-h-[60vh] overflow-y-auto">
|
||||
{BLOCK_TYPES_TO_ADD.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => {
|
||||
editor.addNode(t);
|
||||
setShowAddMenu(false);
|
||||
}}
|
||||
className="text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded"
|
||||
>
|
||||
{BLOCK_TYPE_LABELS[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 侧边内容编辑面板 */}
|
||||
{panelOpen && editor.selectedNodeId && (
|
||||
<div className="w-[420px] flex-shrink-0">
|
||||
<NodeEditPanel
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
classes={classes}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<VersionHistoryDrawer
|
||||
open={showVersions}
|
||||
onClose={() => setShowVersions(false)}
|
||||
planId={planId}
|
||||
onReverted={handleReverted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDebounce } from "@/shared/hooks/use-debounce";
|
||||
|
||||
interface Props {
|
||||
onFilter: (params: {
|
||||
query?: string;
|
||||
subjectId?: string;
|
||||
status?: string;
|
||||
}) => void;
|
||||
subjects: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function LessonPlanFilters({ onFilter, subjects }: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [subjectId, setSubjectId] = useState<string>("");
|
||||
const [status, setStatus] = useState<string>("");
|
||||
// 修复 P1-6:搜索 debounce 300ms
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
|
||||
useEffect(() => {
|
||||
onFilter({
|
||||
query: debouncedQuery || undefined,
|
||||
subjectId: subjectId || undefined,
|
||||
status: status || undefined,
|
||||
});
|
||||
}, [debouncedQuery, subjectId, status, onFilter]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
placeholder="搜索标题..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<select
|
||||
value={subjectId}
|
||||
onChange={(e) => setSubjectId(e.target.value)}
|
||||
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">全部学科</option>
|
||||
{subjects.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="published">已发布</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { LessonPlanCard } from "./lesson-plan-card";
|
||||
import { LessonPlanFilters } from "./lesson-plan-filters";
|
||||
import { getLessonPlansAction } from "../actions";
|
||||
import type { LessonPlanListItem } from "../types";
|
||||
|
||||
interface Props {
|
||||
initialItems: LessonPlanListItem[];
|
||||
subjects: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function LessonPlanList({ initialItems, subjects }: Props) {
|
||||
const [items, setItems] = useState(initialItems);
|
||||
|
||||
async function handleFilter(params: {
|
||||
query?: string;
|
||||
subjectId?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
const res = await getLessonPlansAction(params);
|
||||
if (res.success && res.data) setItems(res.data.items);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<LessonPlanFilters onFilter={handleFilter} subjects={subjects} />
|
||||
{items.length === 0 ? (
|
||||
<p className="text-on-surface-variant text-center py-12">
|
||||
暂无课案,点击“新建课案”开始
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{items.map((p) => (
|
||||
<LessonPlanCard key={p.id} plan={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
src/modules/lesson-preparation/components/node-edit-panel.tsx
Normal file
103
src/modules/lesson-preparation/components/node-edit-panel.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||||
import { RICH_TEXT_BLOCK_TYPES } from "../constants";
|
||||
import { RichTextBlock } from "./blocks/rich-text-block";
|
||||
import { ExerciseBlock } from "./blocks/exercise-block";
|
||||
import { TextStudyBlock } from "./blocks/text-study-block";
|
||||
import { ReflectionBlock } from "./blocks/reflection-block";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Trash2, X } from "lucide-react";
|
||||
import type {
|
||||
ExerciseBlockData,
|
||||
RichTextBlockData,
|
||||
TextStudyBlockData,
|
||||
} from "../types";
|
||||
|
||||
interface Props {
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
classes?: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
|
||||
const { doc, selectedNodeId, updateNode, removeNode, selectNode } =
|
||||
useLessonPlanEditor();
|
||||
|
||||
const node = doc.nodes.find((n) => n.id === selectedNodeId);
|
||||
|
||||
if (!node) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-on-surface-variant text-sm p-4">
|
||||
点击节点编辑内容,或拖拽连线建立流程
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isRichText = RICH_TEXT_BLOCK_TYPES.includes(node.type);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col border-l border-outline-variant bg-surface">
|
||||
{/* 面板头部 */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant">
|
||||
<input
|
||||
value={node.title}
|
||||
onChange={(e) => updateNode(node.id, { title: e.target.value })}
|
||||
className="flex-1 bg-transparent font-title-md text-title-md focus:outline-none"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => selectNode(null)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 内容编辑区 */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{isRichText ? (
|
||||
<RichTextBlock
|
||||
data={node.data as RichTextBlockData}
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
onUpdate={(d) => updateNode(node.id, { data: d })}
|
||||
/>
|
||||
) : node.type === "exercise" ? (
|
||||
<ExerciseBlock
|
||||
blockId={node.id}
|
||||
data={node.data as ExerciseBlockData}
|
||||
classes={classes ?? []}
|
||||
/>
|
||||
) : node.type === "text_study" ? (
|
||||
<TextStudyBlock
|
||||
blockId={node.id}
|
||||
data={node.data as TextStudyBlockData}
|
||||
/>
|
||||
) : node.type === "reflection" ? (
|
||||
<ReflectionBlock
|
||||
data={node.data as RichTextBlockData}
|
||||
onUpdate={(d) => updateNode(node.id, { data: d })}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-on-surface-variant text-sm p-4">
|
||||
未知节点类型
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
<div className="px-4 py-2 border-t border-outline-variant">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-error"
|
||||
onClick={() => removeNode(node.id)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 mr-1" />
|
||||
删除此节点
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
src/modules/lesson-preparation/components/node-editor.tsx
Normal file
155
src/modules/lesson-preparation/components/node-editor.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
type Node,
|
||||
type Edge,
|
||||
type NodeChange,
|
||||
type EdgeChange,
|
||||
type Connection,
|
||||
applyNodeChanges,
|
||||
applyEdgeChanges,
|
||||
BackgroundVariant,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||||
import { LessonNode } from "./nodes/lesson-node";
|
||||
import type { LessonPlanNode } from "../types";
|
||||
|
||||
const nodeTypes = { lesson: LessonNode };
|
||||
|
||||
interface Props {
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
classes?: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function NodeEditor({ textbookId, chapterId, classes }: Props) {
|
||||
const { doc, selectedNodeId, updateNodePosition, removeNode, connect, selectNode, setEdges } =
|
||||
useLessonPlanEditor();
|
||||
|
||||
// 我们的 nodes → React Flow nodes
|
||||
const rfNodes: Node[] = useMemo(
|
||||
() =>
|
||||
doc.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: "lesson",
|
||||
position: n.position,
|
||||
data: n as unknown as Record<string, unknown>,
|
||||
selected: n.id === selectedNodeId,
|
||||
})),
|
||||
[doc.nodes, selectedNodeId],
|
||||
);
|
||||
|
||||
// edges 直接兼容
|
||||
const rfEdges: Edge[] = useMemo(
|
||||
() =>
|
||||
doc.edges.map((e) => ({
|
||||
...e,
|
||||
animated: true,
|
||||
style: { stroke: "#1976d2", strokeWidth: 2 },
|
||||
})),
|
||||
[doc.edges],
|
||||
);
|
||||
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
changes.forEach((change) => {
|
||||
if (change.type === "position" && change.position) {
|
||||
updateNodePosition(change.id, change.position);
|
||||
} else if (change.type === "remove") {
|
||||
removeNode(change.id);
|
||||
} else if (change.type === "select") {
|
||||
selectNode(change.selected ? change.id : null);
|
||||
}
|
||||
});
|
||||
// applyNodeChanges 用于内部状态同步,但我们用 zustand 管理,这里不需要
|
||||
void applyNodeChanges;
|
||||
},
|
||||
[updateNodePosition, removeNode, selectNode],
|
||||
);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(conn: Connection) => {
|
||||
if (conn.source && conn.target) {
|
||||
connect(conn.source, conn.target);
|
||||
}
|
||||
},
|
||||
[connect],
|
||||
);
|
||||
|
||||
// 同步 edges 变化(如拖拽重连)
|
||||
const onEdgesChangeSync = useCallback(
|
||||
(changes: EdgeChange[]) => {
|
||||
// 简单处理:删除时调用 disconnect
|
||||
const nextEdges = applyEdgeChanges(changes, rfEdges);
|
||||
const ourEdges = nextEdges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle: e.sourceHandle ?? null,
|
||||
targetHandle: e.targetHandle ?? null,
|
||||
}));
|
||||
setEdges(ourEdges);
|
||||
},
|
||||
[rfEdges, setEdges],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<ReactFlow
|
||||
nodes={rfNodes}
|
||||
edges={rfEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChangeSync}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={(_, node) => selectNode(node.id)}
|
||||
onPaneClick={() => selectNode(null)}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2, maxZoom: 1.2 }}
|
||||
defaultEdgeOptions={{
|
||||
animated: true,
|
||||
style: { stroke: "#1976d2", strokeWidth: 2 },
|
||||
}}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
className="bg-surface-container-low"
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={20}
|
||||
size={1}
|
||||
color="#ccc"
|
||||
/>
|
||||
<Controls className="!bg-surface !border-outline-variant" />
|
||||
<MiniMap
|
||||
className="!bg-surface !border-outline-variant"
|
||||
nodeColor={(n) => {
|
||||
const data = n.data as unknown as LessonPlanNode;
|
||||
const colors: Record<string, string> = {
|
||||
objective: "#4caf50",
|
||||
key_point: "#f44336",
|
||||
import: "#2196f3",
|
||||
new_teaching: "#9c27b0",
|
||||
consolidation: "#ff9800",
|
||||
summary: "#607d8b",
|
||||
homework: "#795548",
|
||||
blackboard: "#009688",
|
||||
text_study: "#3f51b5",
|
||||
exercise: "#e91e63",
|
||||
rich_text: "#9e9e9e",
|
||||
reflection: "#cddc39",
|
||||
};
|
||||
return colors[data.type] ?? "#9e9e9e";
|
||||
}}
|
||||
/>
|
||||
</ReactFlow>
|
||||
{/* 隐藏的 props 传递,避免 unused 警告 */}
|
||||
<span className="hidden" data-textbook={textbookId} data-chapter={chapterId} data-classes={classes?.length} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import { BLOCK_TYPE_LABELS } from "../../constants";
|
||||
import type { LessonPlanNode } from "../../types";
|
||||
|
||||
// 节点类型 → 图标颜色(Material Design 色板)
|
||||
const NODE_COLORS: Record<string, string> = {
|
||||
objective: "#4caf50",
|
||||
key_point: "#f44336",
|
||||
import: "#2196f3",
|
||||
new_teaching: "#9c27b0",
|
||||
consolidation: "#ff9800",
|
||||
summary: "#607d8b",
|
||||
homework: "#795548",
|
||||
blackboard: "#009688",
|
||||
text_study: "#3f51b5",
|
||||
exercise: "#e91e63",
|
||||
rich_text: "#9e9e9e",
|
||||
reflection: "#cddc39",
|
||||
};
|
||||
|
||||
function getNodeSummary(node: LessonPlanNode): string {
|
||||
const data = node.data as {
|
||||
html?: string;
|
||||
sourceText?: string;
|
||||
items?: unknown[];
|
||||
knowledgePointIds?: string[];
|
||||
};
|
||||
if (data.items !== undefined) {
|
||||
return `${data.items.length} 道题`;
|
||||
}
|
||||
if (data.sourceText !== undefined && data.sourceText) {
|
||||
return `${data.sourceText.length} 字`;
|
||||
}
|
||||
if (data.html) {
|
||||
// 去标签后取前 40 字
|
||||
const text = data.html.replace(/<[^>]+>/g, "").trim();
|
||||
return text.slice(0, 40) || "空";
|
||||
}
|
||||
return "空";
|
||||
}
|
||||
|
||||
export const LessonNode = memo(function LessonNode({
|
||||
data,
|
||||
selected,
|
||||
}: NodeProps) {
|
||||
const nodeData = data as unknown as LessonPlanNode;
|
||||
const color = NODE_COLORS[nodeData.type] ?? "#9e9e9e";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border-2 bg-surface shadow-md min-w-[200px] max-w-[260px] transition-shadow"
|
||||
style={{
|
||||
borderColor: selected ? "#1976d2" : color,
|
||||
boxShadow: selected ? "0 0 0 2px rgba(25,118,210,0.3)" : undefined,
|
||||
}}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!bg-on-surface !w-3 !h-3 !border-2 !border-surface"
|
||||
/>
|
||||
<div
|
||||
className="px-3 py-2 rounded-t-md text-white text-xs font-medium flex items-center gap-1"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
<span>{BLOCK_TYPE_LABELS[nodeData.type] ?? nodeData.type}</span>
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-sm font-medium text-on-surface truncate">
|
||||
{nodeData.title}
|
||||
</div>
|
||||
<div className="text-xs text-on-surface-variant mt-1">
|
||||
{getNodeSummary(nodeData)}
|
||||
</div>
|
||||
</div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="!bg-on-surface !w-3 !h-3 !border-2 !border-surface"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { publishLessonPlanHomeworkAction } from "../actions-publish";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
planId: string;
|
||||
blockId: string;
|
||||
classes: { id: string; name: string }[];
|
||||
onClose: () => void;
|
||||
onPublished: () => void;
|
||||
}
|
||||
|
||||
export function PublishHomeworkDialog({
|
||||
planId,
|
||||
blockId,
|
||||
classes,
|
||||
onClose,
|
||||
onPublished,
|
||||
}: Props) {
|
||||
const [selectedClasses, setSelectedClasses] = useState<string[]>([]);
|
||||
const [availableAt, setAvailableAt] = useState("");
|
||||
const [dueAt, setDueAt] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handlePublish() {
|
||||
if (selectedClasses.length === 0) {
|
||||
setError("请选择至少一个班级");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await publishLessonPlanHomeworkAction({
|
||||
planId,
|
||||
blockId,
|
||||
classIds: selectedClasses,
|
||||
availableAt: availableAt || undefined,
|
||||
dueAt: dueAt || undefined,
|
||||
});
|
||||
setLoading(false);
|
||||
if (res.success) {
|
||||
onPublished();
|
||||
onClose();
|
||||
} else {
|
||||
setError(res.message ?? "发布失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-96">
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="font-title-md">发布为作业</h3>
|
||||
<button onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">下发班级</label>
|
||||
<div className="mt-1 space-y-1 max-h-40 overflow-y-auto">
|
||||
{classes.map((c) => (
|
||||
<label
|
||||
key={c.id}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedClasses.includes(c.id)}
|
||||
onChange={() =>
|
||||
setSelectedClasses(
|
||||
selectedClasses.includes(c.id)
|
||||
? selectedClasses.filter((x) => x !== c.id)
|
||||
: [...selectedClasses, c.id],
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">{c.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
开始时间(可选)
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={availableAt}
|
||||
onChange={(e) => setAvailableAt(e.target.value)}
|
||||
className="w-full border rounded px-2 py-1 mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
截止时间(可选)
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={dueAt}
|
||||
onChange={(e) => setDueAt(e.target.value)}
|
||||
className="w-full border rounded px-2 py-1 mt-1"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-error text-sm">{error}</p>}
|
||||
</div>
|
||||
<div className="p-4 border-t flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handlePublish} disabled={loading}>
|
||||
{loading ? "发布中..." : "发布"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { getQuestionsAction } from "@/modules/questions/actions"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { useDebounce } from "@/shared/hooks/use-debounce"
|
||||
import { X } from "lucide-react"
|
||||
import { QuestionBankFilters } from "@/shared/components/question/question-bank-filters"
|
||||
import type { ExerciseItem } from "../types"
|
||||
import type { QuestionType } from "@/modules/questions/types"
|
||||
|
||||
interface QuestionRow {
|
||||
id: string
|
||||
type: string
|
||||
difficulty: number
|
||||
content: unknown
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onPick: (items: ExerciseItem[]) => void
|
||||
onClose: () => void
|
||||
existingIds: string[]
|
||||
}
|
||||
|
||||
export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
||||
const [questions, setQuestions] = useState<QuestionRow[]>([])
|
||||
const [picked, setPicked] = useState<ExerciseItem[]>([])
|
||||
|
||||
// QuestionBankFilters 使用字符串值,这里转换为 filters 对象
|
||||
const [searchValue, setSearchValue] = useState("")
|
||||
const [typeValue, setTypeValue] = useState<string>("all")
|
||||
const [difficultyValue, setDifficultyValue] = useState<string>("all")
|
||||
|
||||
const filters = useMemo<{
|
||||
q?: string
|
||||
type?: QuestionType
|
||||
difficulty?: number
|
||||
}>(() => {
|
||||
const newFilters: {
|
||||
q?: string
|
||||
type?: QuestionType
|
||||
difficulty?: number
|
||||
} = {}
|
||||
if (searchValue) newFilters.q = searchValue
|
||||
if (typeValue !== "all") newFilters.type = typeValue as QuestionType
|
||||
if (difficultyValue !== "all") newFilters.difficulty = Number(difficultyValue)
|
||||
return newFilters
|
||||
}, [searchValue, typeValue, difficultyValue])
|
||||
|
||||
// 修复 P1-5:搜索 debounce 300ms
|
||||
const debouncedFilters = useDebounce(filters, 300)
|
||||
|
||||
useEffect(() => {
|
||||
getQuestionsAction(debouncedFilters).then((res) => {
|
||||
if (res.success && res.data) {
|
||||
const data = res.data.data
|
||||
setQuestions(
|
||||
data.map((q) => ({
|
||||
id: q.id,
|
||||
type: q.type,
|
||||
difficulty: q.difficulty,
|
||||
content: q.content,
|
||||
})),
|
||||
)
|
||||
}
|
||||
})
|
||||
}, [debouncedFilters])
|
||||
|
||||
function add(q: QuestionRow) {
|
||||
if (existingIds.includes(q.id) || picked.some((p) => p.questionId === q.id)) return
|
||||
setPicked((prev) => [
|
||||
...prev,
|
||||
{
|
||||
questionId: q.id,
|
||||
source: "bank",
|
||||
score: 5,
|
||||
order: prev.length,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
function previewText(content: unknown): string {
|
||||
if (typeof content === "string") return content.slice(0, 80)
|
||||
try {
|
||||
return JSON.stringify(content).slice(0, 80)
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-[700px] max-h-[80vh] flex flex-col">
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="font-title-md">从题库选择题目</h3>
|
||||
<button onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 border-b">
|
||||
<QuestionBankFilters
|
||||
search={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
type={typeValue}
|
||||
onTypeChange={setTypeValue}
|
||||
difficulty={difficultyValue}
|
||||
onDifficultyChange={setDifficultyValue}
|
||||
layout="default"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-2">
|
||||
{questions.map((q) => (
|
||||
<div
|
||||
key={q.id}
|
||||
className="border rounded p-2 flex justify-between items-center"
|
||||
>
|
||||
<span className="text-sm truncate flex-1 mr-2">{previewText(q.content)}</span>
|
||||
<span className="text-xs text-on-surface-variant mr-2">
|
||||
{q.type} · {q.difficulty}星
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={() => add(q)}>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t flex justify-between">
|
||||
<span className="text-sm">已选 {picked.length} 题</span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onPick(picked)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
插入
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createLessonPlanAction } from "../actions";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { SYSTEM_TEMPLATES } from "../constants";
|
||||
|
||||
export function TemplatePicker() {
|
||||
const router = useRouter();
|
||||
const [selected, setSelected] = useState<string>("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
setError(null);
|
||||
formData.set("templateId", selected);
|
||||
formData.set("title", title);
|
||||
const res = await createLessonPlanAction(null, formData);
|
||||
if (res.success && res.data) {
|
||||
router.push(`/teacher/lesson-plans/${res.data.planId}/edit`);
|
||||
} else {
|
||||
setError(res.message ?? "创建失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={handleSubmit} className="max-w-3xl mx-auto p-6 space-y-6">
|
||||
<div>
|
||||
<label className="font-title-md block mb-2">课案标题</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
className="w-full border border-outline-variant rounded-lg px-3 py-2"
|
||||
placeholder="例如:《秋天》第一课时"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-title-md block mb-2">选择模板</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{SYSTEM_TEMPLATES.map((t) => (
|
||||
<button
|
||||
type="button"
|
||||
key={t.id}
|
||||
onClick={() => setSelected(t.id)}
|
||||
className={`text-left p-4 border-2 rounded-lg transition-colors ${
|
||||
selected === t.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-outline-variant hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="font-title-md">{t.name}</div>
|
||||
<div className="text-sm text-on-surface-variant mt-1">
|
||||
{t.blocks.length === 0
|
||||
? "从空白开始"
|
||||
: `${t.blocks.length} 个环节`}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-error text-sm">{error}</p>}
|
||||
<Button type="submit" disabled={!selected || !title}>
|
||||
创建课案
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
getLessonPlanVersionsAction,
|
||||
revertLessonPlanVersionAction,
|
||||
} from "../actions";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import type { LessonPlanVersion } from "../types";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
planId: string;
|
||||
onReverted: () => void;
|
||||
}
|
||||
|
||||
export function VersionHistoryDrawer({
|
||||
open,
|
||||
onClose,
|
||||
planId,
|
||||
onReverted,
|
||||
}: Props) {
|
||||
const [versions, setVersions] = useState<LessonPlanVersion[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
// 用微任务延迟避免同步 setState 触发级联渲染
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(true);
|
||||
getLessonPlanVersionsAction(planId).then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.success && res.data) setVersions(res.data.versions);
|
||||
setLoading(false);
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, planId]);
|
||||
|
||||
async function handleRevert(versionNo: number) {
|
||||
if (!confirm(`确认回退到 v${versionNo}?将生成新版本。`)) return;
|
||||
const res = await revertLessonPlanVersionAction({ planId, versionNo });
|
||||
if (res.success) {
|
||||
onReverted();
|
||||
onClose();
|
||||
} else {
|
||||
alert(res.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex">
|
||||
<div className="flex-1 bg-black/30" onClick={onClose} />
|
||||
<div className="w-96 bg-surface border-l border-outline-variant overflow-y-auto p-4">
|
||||
<h3 className="font-headline-md text-headline-md mb-4">版本历史</h3>
|
||||
{loading ? (
|
||||
<p>加载中...</p>
|
||||
) : versions.length === 0 ? (
|
||||
<p className="text-on-surface-variant">暂无版本</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{versions.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className="border border-outline-variant rounded-lg p-3"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-title-md">v{v.versionNo}</span>
|
||||
{v.isAuto && (
|
||||
<span className="text-xs bg-surface-container-highest px-2 py-0.5 rounded">
|
||||
自动
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
{v.label ?? "手动保存"}
|
||||
</p>
|
||||
<p className="text-xs text-on-surface-variant mt-1">
|
||||
{new Date(v.createdAt).toLocaleString()}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => handleRevert(v.versionNo)}
|
||||
>
|
||||
回退到此版本
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user