feat(lesson-preparation): 备课模块审计重构 — 跨模块解耦 + i18n + 纯函数抽取 + 错误边界

P0-1 跨模块直查修复:publish-service 不再直查 examQuestions 表,新增 exams/data-access.addExamQuestions 接口,复用 classes/data-access.getStudentIdsByClassIds

P0-2 i18n 接入:新增 zh-CN/en 翻译文件,注册 lessonPreparation 命名空间,17 个组件改造为 useTranslations/getTranslations

P1 纯函数抽取:lib/document-migration.ts(类型守卫替代 as 断言)、lib/node-summary.ts(翻译函数注入)、lib/rf-mappers.ts

P1 错误边界+骨架屏:新增 LessonPlanErrorBoundary 和 4 个 Skeleton 组件

P1 Block 注册表:新增 config/block-registry.tsx(BlockRenderer 组件),node-edit-panel 重构为配置驱动渲染

P1 其他修复:exercise-block 改用 router.refresh(),node-editor/lesson-node 复用 lib/ 纯函数

架构图同步:更新 004 和 005 文档

Refs: docs/architecture/audit/lesson-preparation-audit-report.md
This commit is contained in:
SpecialX
2026-06-22 16:17:58 +08:00
parent 4833930834
commit 20691f53ce
32 changed files with 1456 additions and 360 deletions

View File

@@ -1,6 +1,8 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
import { QuestionBankPicker } from "../question-bank-picker";
import { InlineQuestionEditor } from "../inline-question-editor";
@@ -10,15 +12,20 @@ import { Plus, Trash2 } from "lucide-react";
import type {
ExerciseBlockData,
ExerciseItem,
ExercisePurpose,
} from "../../types";
interface Props {
blockId: string;
data: ExerciseBlockData;
classes: { id: string; name: string }[];
textbookId?: string;
chapterId?: string;
}
export function ExerciseBlock({ blockId, data, classes }: Props) {
export function ExerciseBlock({ blockId, data, classes, textbookId, chapterId }: Props) {
const t = useTranslations("lessonPreparation");
const router = useRouter();
const { updateNode, planId } = useLessonPlanEditor();
const [showBank, setShowBank] = useState(false);
const [showInline, setShowInline] = useState(false);
@@ -49,34 +56,34 @@ export function ExerciseBlock({ blockId, data, classes }: Props) {
<select
value={data.purpose}
onChange={(e) =>
update({ purpose: e.target.value as never })
update({ purpose: e.target.value as ExercisePurpose })
}
className="border rounded px-2 py-1 text-sm"
>
<option value="class_practice"></option>
<option value="after_class_homework"></option>
<option value="class_practice">{t("exercise.purpose.class_practice")}</option>
<option value="after_class_homework">{t("exercise.purpose.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">
{t("questionBank.empty")}
</p>
) : (
<div className="space-y-1">
{data.items.map((item, idx) => (
<div
key={idx}
key={item.questionId}
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" ? "题库" : "新建"}
{item.source === "bank" ? t("questionBank.source.bank") : t("questionBank.source.inline")}
</span>
<span className="text-sm flex-1 truncate">
{item.source === "bank"
? `题目 ${item.questionId.slice(0, 8)}`
: "课案内新建题目"}
? t("questionBank.questionId", { id: item.questionId.slice(0, 8) })
: t("questionBank.inlineQuestion")}
</span>
<span className="text-xs">{item.score}</span>
<span className="text-xs">{t("questionBank.score", { score: item.score })}</span>
<button onClick={() => removeItem(idx)}>
<Trash2 className="w-3 h-3 text-error" />
</button>
@@ -91,7 +98,7 @@ export function ExerciseBlock({ blockId, data, classes }: Props) {
onClick={() => setShowBank(true)}
>
<Plus className="w-3 h-3 mr-1" />
{t("questionBank.fromBank")}
</Button>
<Button
variant="outline"
@@ -99,18 +106,18 @@ export function ExerciseBlock({ blockId, data, classes }: Props) {
onClick={() => setShowInline(true)}
>
<Plus className="w-3 h-3 mr-1" />
{t("questionBank.inlineNew")}
</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">
{t("status.publishedAsHomework")}
</span>
<a
href="/teacher/homework"
className="text-primary underline"
>
{t("action.viewHomework")}
</a>
</div>
) : (
@@ -120,7 +127,7 @@ export function ExerciseBlock({ blockId, data, classes }: Props) {
size="sm"
onClick={() => setShowPublish(true)}
>
{t("action.publish")}
</Button>
)
)}
@@ -134,6 +141,8 @@ export function ExerciseBlock({ blockId, data, classes }: Props) {
)}
{showInline && (
<InlineQuestionEditor
textbookId={textbookId}
chapterId={chapterId}
onAdd={(item) => {
addItems([item]);
setShowInline(false);
@@ -147,7 +156,7 @@ export function ExerciseBlock({ blockId, data, classes }: Props) {
blockId={blockId}
classes={classes}
onClose={() => setShowPublish(false)}
onPublished={() => window.location.reload()}
onPublished={() => router.refresh()}
/>
)}
</div>

View File

@@ -1,5 +1,6 @@
"use client";
import { useTranslations } from "next-intl";
import { RichTextBlock } from "./rich-text-block";
import type { RichTextBlockData } from "../../types";
@@ -9,6 +10,7 @@ interface Props {
}
export function ReflectionBlock(props: Props) {
const t = useTranslations("lessonPreparation");
// 教学反思在 P1 阶段与普通富文本一致P3 再扩展学情数据嵌入
return <RichTextBlock {...props} hint="课后填写教学反思..." />;
return <RichTextBlock {...props} hint={t("reflection.hint")} />;
}

View File

@@ -4,6 +4,7 @@ import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import type { RichTextBlockData } from "../../types";
import { KnowledgePointPicker } from "../knowledge-point-picker";
import { Tag } from "lucide-react";
@@ -23,10 +24,11 @@ export function RichTextBlock({
chapterId,
onUpdate,
}: Props) {
const t = useTranslations("lessonPreparation");
const editor = useEditor({
extensions: [
StarterKit,
Placeholder.configure({ placeholder: hint ?? "输入内容..." }),
Placeholder.configure({ placeholder: hint ?? t("richText.placeholder") }),
],
content: data.html,
immediatelyRender: false,
@@ -56,7 +58,7 @@ export function RichTextBlock({
<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}
{t("knowledgePoint.linked", { count: data.knowledgePointIds.length })}
</span>
)}
<button
@@ -64,7 +66,7 @@ export function RichTextBlock({
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 && (
@@ -78,4 +80,4 @@ export function RichTextBlock({
)}
</div>
);
}
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
import { Button } from "@/shared/components/ui/button";
import { Plus, Trash2 } from "lucide-react";
@@ -16,6 +17,7 @@ interface Props {
}
export function TextStudyBlock({ blockId, data }: Props) {
const t = useTranslations("lessonPreparation");
const { updateNode } = useLessonPlanEditor();
const [selection, setSelection] = useState<{
start: number;
@@ -26,26 +28,23 @@ export function TextStudyBlock({ blockId, data }: Props) {
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;
function handleTextSelect(e: React.MouseEvent<HTMLTextAreaElement>) {
const textarea = e.currentTarget;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
if (end > start) setSelection({ start, end });
}
function addAnnotation() {
if (!selection) {
alert("请先在课文中选中一段文本");
alert(t("textStudy.selectFirst"));
return;
}
const ann: TextStudyAnnotation = {
id: createId(),
anchor: selection,
nodeType: "language_feature",
title: "教学节点",
title: t("textStudy.annotationTitle"),
note: "",
color: "yellow",
};
@@ -62,13 +61,13 @@ export function TextStudyBlock({ blockId, data }: Props) {
return (
<div className="space-y-3">
<div>
<label className="text-sm font-medium"></label>
<label className="text-sm font-medium">{t("textStudy.sourceTextLabel")}</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="粘贴课文原文,选中文本后可添加教学节点"
placeholder={t("textStudy.sourceTextPlaceholder")}
/>
</div>
<Button
@@ -78,7 +77,7 @@ export function TextStudyBlock({ blockId, data }: Props) {
disabled={!selection}
>
<Plus className="w-3 h-3 mr-1" />
{t("textStudy.addAnnotation")}
</Button>
{data.annotations.length > 0 && (
<div className="space-y-2">
@@ -117,7 +116,7 @@ export function TextStudyBlock({ blockId, data }: Props) {
})
}
className="w-full text-sm border rounded p-1 mt-1 min-h-[40px]"
placeholder="教学说明..."
placeholder={t("textStudy.annotationNotePlaceholder")}
/>
</div>
))}

View File

@@ -1,17 +1,22 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { createId } from "@paralleldrive/cuid2";
import { Button } from "@/shared/components/ui/button";
import { X } from "lucide-react";
import { X, Tag } from "lucide-react";
import { KnowledgePointPicker } from "./knowledge-point-picker";
import type { ExerciseItem, InlineQuestionContent } from "../types";
interface Props {
onAdd: (item: ExerciseItem) => void;
onClose: () => void;
textbookId?: string;
chapterId?: string;
}
export function InlineQuestionEditor({ onAdd, onClose }: Props) {
export function InlineQuestionEditor({ onAdd, onClose, textbookId, chapterId }: Props) {
const t = useTranslations("lessonPreparation");
const [type, setType] = useState<
"single_choice" | "text" | "judgment"
>("single_choice");
@@ -19,11 +24,12 @@ export function InlineQuestionEditor({ onAdd, onClose }: Props) {
const [text, setText] = useState("");
const [options, setOptions] = useState<string[]>(["", ""]);
const [correctIdx, setCorrectIdx] = useState(0);
const kpIds: string[] = [];
const [kpIds, setKpIds] = useState<string[]>([]);
const [showKpPicker, setShowKpPicker] = useState(false);
function handleAdd() {
if (!text.trim()) {
alert("请输入题干");
alert(t("questionBank.stemRequired"));
return;
}
const content: Record<string, unknown> =
@@ -59,26 +65,26 @@ export function InlineQuestionEditor({ onAdd, onClose }: Props) {
<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>
<h3 className="font-title-md">{t("questionBank.inlineTitle")}</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>
<label className="text-sm font-medium">{t("questionBank.typeLabel")}</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>
<option value="single_choice">{t("questionBank.type.single_choice")}</option>
<option value="text">{t("questionBank.type.text")}</option>
<option value="judgment">{t("questionBank.type.judgment")}</option>
</select>
</div>
<div>
<label className="text-sm font-medium"></label>
<label className="text-sm font-medium">{t("questionBank.stemLabel")}</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
@@ -88,7 +94,7 @@ export function InlineQuestionEditor({ onAdd, onClose }: Props) {
{type === "single_choice" && (
<div>
<label className="text-sm font-medium">
{t("questionBank.optionsLabel")}
</label>
{options.map((opt, i) => (
<div key={i} className="flex items-center gap-2 mt-1">
@@ -124,14 +130,14 @@ export function InlineQuestionEditor({ onAdd, onClose }: Props) {
onClick={() => setOptions([...options, ""])}
className="text-sm text-primary mt-1"
>
+
{t("questionBank.addOption")}
</button>
)}
</div>
)}
{type === "judgment" && (
<div>
<label className="text-sm font-medium"></label>
<label className="text-sm font-medium">{t("questionBank.correctAnswer")}</label>
<div className="flex gap-3 mt-1">
<label className="flex items-center gap-1">
<input
@@ -139,7 +145,7 @@ export function InlineQuestionEditor({ onAdd, onClose }: Props) {
checked={correctIdx === 0}
onChange={() => setCorrectIdx(0)}
/>
{t("questionBank.correct")}
</label>
<label className="flex items-center gap-1">
<input
@@ -147,13 +153,13 @@ export function InlineQuestionEditor({ onAdd, onClose }: Props) {
checked={correctIdx === 1}
onChange={() => setCorrectIdx(1)}
/>
{t("questionBank.incorrect")}
</label>
</div>
</div>
)}
<div>
<label className="text-sm font-medium"></label>
<label className="text-sm font-medium">{t("questionBank.difficultyLabel")}</label>
<select
value={difficulty}
onChange={(e) => setDifficulty(Number(e.target.value))}
@@ -161,19 +167,46 @@ export function InlineQuestionEditor({ onAdd, onClose }: Props) {
>
{[1, 2, 3, 4, 5].map((d) => (
<option key={d} value={d}>
{d}
{t("questionBank.difficulty", { level: d })}
</option>
))}
</select>
</div>
<div>
<label className="text-sm font-medium">{t("questionBank.knowledgePointLabel")}</label>
<div className="flex items-center gap-2 mt-1">
{kpIds.length > 0 && (
<span className="text-xs text-on-surface-variant">
{t("knowledgePoint.selected", { count: kpIds.length })}
</span>
)}
<button
type="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.select")}
</button>
</div>
</div>
</div>
<div className="p-4 border-t flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
{t("action.cancel")}
</Button>
<Button onClick={handleAdd}></Button>
<Button onClick={handleAdd}>{t("questionBank.addBtn")}</Button>
</div>
</div>
{showKpPicker && (
<KnowledgePointPicker
textbookId={textbookId}
chapterId={chapterId}
selectedIds={kpIds}
onChange={setKpIds}
onClose={() => setShowKpPicker(false)}
/>
)}
</div>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components/ui/button";
import { X } from "lucide-react";
import { getKnowledgePointOptionsAction } from "../actions-kp";
@@ -25,6 +26,7 @@ export function KnowledgePointPicker({
onChange,
onClose,
}: Props) {
const t = useTranslations("lessonPreparation");
const [options, setOptions] = useState<KpOption[]>([]);
const [local, setLocal] = useState<string[]>(selectedIds);
@@ -47,7 +49,7 @@ export function KnowledgePointPicker({
<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>
<h3 className="font-title-md">{t("knowledgePoint.title")}</h3>
<button onClick={onClose}>
<X className="w-4 h-4" />
</button>
@@ -55,7 +57,7 @@ export function KnowledgePointPicker({
<div className="flex-1 overflow-y-auto p-4">
{options.length === 0 ? (
<p className="text-on-surface-variant text-sm">
{t("knowledgePoint.empty")}
</p>
) : (
<div className="space-y-1">
@@ -77,7 +79,7 @@ export function KnowledgePointPicker({
</div>
<div className="p-4 border-t border-outline-variant flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={onClose}>
{t("action.cancel")}
</Button>
<Button
size="sm"
@@ -86,7 +88,7 @@ export function KnowledgePointPicker({
onClose();
}}
>
{t("action.confirm")}
</Button>
</div>
</div>

View File

@@ -1,12 +1,17 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components/ui/button";
import { LESSON_PLAN_STATUS_LABELS } from "../constants";
import { formatDateTime } from "@/shared/lib/utils";
import { duplicateLessonPlanAction, deleteLessonPlanAction } from "../actions";
import type { LessonPlanListItem } from "../types";
export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
const t = useTranslations("lessonPreparation");
const router = useRouter();
return (
<div className="border border-outline-variant rounded-lg p-4 bg-surface-container-lowest hover:shadow-md transition-shadow">
<Link
@@ -18,17 +23,17 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
</h3>
</Link>
<div className="text-sm text-on-surface-variant mt-1">
{plan.textbookTitle ?? "无教材"} · {plan.chapterTitle ?? "无章节"}
{plan.textbookTitle ?? t("list.noTextbook")} · {plan.chapterTitle ?? t("list.noChapter")}
</div>
<div className="text-xs text-on-surface-variant mt-1">
{plan.templateName ?? "无模板"} ·{" "}
{LESSON_PLAN_STATUS_LABELS[plan.status]}
{plan.templateName ?? t("list.noTemplate")} ·{" "}
{t(`status.${plan.status}`)}
</div>
<div className="text-xs text-on-surface-variant mt-2">
{t("list.lastSaved")}
{plan.lastSavedAt
? new Date(plan.lastSavedAt).toLocaleString()
: "未保存"}
? formatDateTime(plan.lastSavedAt)
: t("list.neverSaved")}
</div>
<div className="flex gap-2 mt-3">
<Button
@@ -36,21 +41,21 @@ export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
size="sm"
onClick={async () => {
const res = await duplicateLessonPlanAction(plan.id);
if (res.success) window.location.reload();
if (res.success) router.refresh();
}}
>
{t("action.duplicate")}
</Button>
<Button
variant="outline"
size="sm"
onClick={async () => {
if (!confirm("确认归档此课案?")) return;
if (!confirm(t("confirm.archive"))) return;
const res = await deleteLessonPlanAction(plan.id);
if (res.success) window.location.reload();
if (res.success) router.refresh();
}}
>
{t("action.archive")}
</Button>
</div>
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
import { NodeEditor } from "./node-editor";
import { NodeEditPanel } from "./node-edit-panel";
@@ -10,7 +11,6 @@ import {
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";
@@ -47,10 +47,10 @@ export function LessonPlanEditor({
chapterId,
classes,
}: Props) {
const t = useTranslations("lessonPreparation");
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);
@@ -62,11 +62,6 @@ export function LessonPlanEditor({
// 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;
@@ -95,13 +90,13 @@ export function LessonPlanEditor({
await saveLessonPlanVersionAction({
planId: state.planId,
content: state.doc,
label: "自动版本",
label: t("version.autoLabel"),
});
}, 30 * 60 * 1000);
return () => {
if (versionTimer.current) clearInterval(versionTimer.current);
};
}, [planId]);
}, [planId, t]);
// 离开未保存提示P3-1
useEffect(() => {
@@ -158,20 +153,20 @@ export function LessonPlanEditor({
/>
<span className="text-on-surface-variant text-sm">
{editor.isSaving
? "保存中..."
? t("status.saving")
: editor.isDirty
? "未保存"
: "已保存"}
? t("status.unsaved")
: t("status.saved")}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setShowVersions(true)}
>
<History className="w-4 h-4 mr-1" />
<History className="w-4 h-4 mr-1" /> {t("action.versions")}
</Button>
<Button size="sm" onClick={handleManualSave} disabled={editor.isSaving}>
<Save className="w-4 h-4 mr-1" />
<Save className="w-4 h-4 mr-1" /> {t("action.saveVersion")}
</Button>
</div>
@@ -179,31 +174,27 @@ export function LessonPlanEditor({
<div className="flex-1 flex overflow-hidden">
{/* 节点画布 */}
<div className="flex-1 relative">
<NodeEditor
textbookId={textbookId}
chapterId={chapterId}
classes={classes}
/>
<NodeEditor />
{/* 添加节点浮动按钮 */}
<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" />
<Plus className="w-4 h-4 mr-1" /> {t("action.addNode")}
</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) => (
{BLOCK_TYPES_TO_ADD.map((blockType) => (
<button
key={t}
key={blockType}
onClick={() => {
editor.addNode(t);
editor.addNode(blockType);
setShowAddMenu(false);
}}
className="text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded"
>
{BLOCK_TYPE_LABELS[t]}
{t(`blockType.${blockType}`)}
</button>
))}
</div>
@@ -211,8 +202,8 @@ export function LessonPlanEditor({
</div>
</div>
{/* 侧边内容编辑面板 */}
{panelOpen && editor.selectedNodeId && (
{/* 侧边内容编辑面板:直接用 selectedNodeId 控制显示(修复 P1-2 */}
{editor.selectedNodeId && (
<div className="w-[420px] flex-shrink-0">
<NodeEditPanel
textbookId={textbookId}

View File

@@ -0,0 +1,59 @@
"use client";
import { Component, type ReactNode, type ErrorInfo } from "react";
import { Button } from "@/shared/components/ui/button";
interface Props {
children: ReactNode;
fallback?: ReactNode;
/** 错误时的回调,用于上报埋点 */
onError?: (error: Error, info: ErrorInfo) => void;
}
interface State {
hasError: boolean;
error: Error | null;
}
/**
* 备课模块错误边界。
* 包裹独立数据区块(版本抽屉/题库选择器/知识点选择器/发布对话框),
* 单个区块异常不影响整页。
*/
export class LessonPlanErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
if (this.props.onError) {
this.props.onError(error, info);
}
}
handleRetry = (): void => {
this.setState({ hasError: false, error: null });
};
render(): ReactNode {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
return (
<div className="flex flex-col items-center justify-center p-8 gap-3 text-center">
<p className="text-sm text-on-surface-variant">
{this.state.error?.message ?? "区块加载失败"}
</p>
<Button variant="outline" size="sm" onClick={this.handleRetry}>
</Button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { useDebounce } from "@/shared/hooks/use-debounce";
interface Props {
@@ -13,6 +14,7 @@ interface Props {
}
export function LessonPlanFilters({ onFilter, subjects }: Props) {
const t = useTranslations("lessonPreparation");
const [query, setQuery] = useState("");
const [subjectId, setSubjectId] = useState<string>("");
const [status, setStatus] = useState<string>("");
@@ -30,7 +32,7 @@ export function LessonPlanFilters({ onFilter, subjects }: Props) {
return (
<div className="flex gap-2 flex-wrap">
<input
placeholder="搜索标题..."
placeholder={t("filters.searchPlaceholder")}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
@@ -40,7 +42,7 @@ export function LessonPlanFilters({ onFilter, subjects }: Props) {
onChange={(e) => setSubjectId(e.target.value)}
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
>
<option value=""></option>
<option value="">{t("filters.allSubjects")}</option>
{subjects.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
@@ -52,9 +54,9 @@ export function LessonPlanFilters({ onFilter, subjects }: Props) {
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>
<option value="">{t("filters.allStatus")}</option>
<option value="draft">{t("status.draft")}</option>
<option value="published">{t("status.published")}</option>
</select>
</div>
);

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { LessonPlanCard } from "./lesson-plan-card";
import { LessonPlanFilters } from "./lesson-plan-filters";
import { getLessonPlansAction } from "../actions";
@@ -12,6 +13,7 @@ interface Props {
}
export function LessonPlanList({ initialItems, subjects }: Props) {
const t = useTranslations("lessonPreparation");
const [items, setItems] = useState(initialItems);
async function handleFilter(params: {
@@ -28,7 +30,7 @@ export function LessonPlanList({ initialItems, subjects }: Props) {
<LessonPlanFilters onFilter={handleFilter} subjects={subjects} />
{items.length === 0 ? (
<p className="text-on-surface-variant text-center py-12">
&ldquo;&rdquo;
{t("list.empty")}
</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">

View File

@@ -0,0 +1,79 @@
import type { JSX } from "react";
import { Skeleton } from "@/shared/components/ui/skeleton";
/** 版本列表骨架屏 */
export function VersionListSkeleton(): JSX.Element {
return (
<div className="flex flex-col gap-2">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="border border-outline-variant rounded-lg p-3 space-y-2"
>
<div className="flex justify-between items-center">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-3 w-8" />
</div>
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-24" />
<Skeleton className="h-6 w-28" />
</div>
))}
</div>
);
}
/** 题库列表骨架屏 */
export function QuestionBankSkeleton(): JSX.Element {
return (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="border rounded p-2 flex justify-between items-center"
>
<Skeleton className="h-4 flex-1 mr-2" />
<Skeleton className="h-3 w-16 mr-2" />
<Skeleton className="h-6 w-12" />
</div>
))}
</div>
);
}
/** 知识点列表骨架屏 */
export function KnowledgePointSkeleton(): JSX.Element {
return (
<div className="space-y-1">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex items-center gap-2 p-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 flex-1" />
</div>
))}
</div>
);
}
/** 课案列表骨架屏 */
export function LessonPlanListSkeleton(): JSX.Element {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="border border-outline-variant rounded-lg p-4 space-y-3"
>
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-3 w-2/3" />
<Skeleton className="h-3 w-1/3" />
<div className="flex gap-2 mt-3">
<Skeleton className="h-7 w-16" />
<Skeleton className="h-7 w-16" />
</div>
</div>
))}
</div>
);
}

View File

@@ -1,18 +1,11 @@
"use client";
import { useTranslations } from "next-intl";
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 { BlockRenderer } from "../config/block-registry";
import { LessonPlanErrorBoundary } from "./lesson-plan-error-boundary";
import { Button } from "@/shared/components/ui/button";
import { Trash2, X } from "lucide-react";
import type {
ExerciseBlockData,
RichTextBlockData,
TextStudyBlockData,
} from "../types";
interface Props {
textbookId?: string;
@@ -21,6 +14,7 @@ interface Props {
}
export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
const t = useTranslations("lessonPreparation");
const { doc, selectedNodeId, updateNode, removeNode, selectNode } =
useLessonPlanEditor();
@@ -29,13 +23,11 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
if (!node) {
return (
<div className="h-full flex items-center justify-center text-on-surface-variant text-sm p-4">
线
{t("editor.selectNodeHint")}
</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">
{/* 面板头部 */}
@@ -44,46 +36,33 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
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"
aria-label={node.title}
/>
<Button
variant="ghost"
size="sm"
onClick={() => selectNode(null)}
aria-label={t("action.close")}
>
<X className="w-4 h-4" />
<X className="w-4 h-4" aria-hidden="true" />
</Button>
</div>
{/* 内容编辑区 */}
{/* 内容编辑区 - 使用 Error Boundary 包裹 + BlockRenderer 配置驱动渲染 */}
<div className="flex-1 overflow-y-auto p-3">
{isRichText ? (
<RichTextBlock
data={node.data as RichTextBlockData}
<LessonPlanErrorBoundary>
<BlockRenderer
type={node.type}
blockId={node.id}
data={node.data}
textbookId={textbookId}
chapterId={chapterId}
classes={classes}
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>
)}
{/* BlockRenderer 返回 null 时显示未知类型提示 */}
<UnknownBlockHint type={node.type} t={t} />
</LessonPlanErrorBoundary>
</div>
{/* 底部操作 */}
@@ -94,10 +73,37 @@ export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
className="text-error"
onClick={() => removeNode(node.id)}
>
<Trash2 className="w-3 h-3 mr-1" />
<Trash2 className="w-3 h-3 mr-1" aria-hidden="true" />
{t("action.delete")}
</Button>
</div>
</div>
);
}
/**
* 未知 Block 类型提示(配置驱动,当 BlockRenderer 返回 null 时显示)。
* 独立组件避免在主组件中条件渲染。
*/
function UnknownBlockHint({
type,
t,
}: {
type: string;
t: ReturnType<typeof useTranslations>;
}) {
// 已知类型不显示提示
const knownTypes = [
"objective", "key_point", "import", "new_teaching", "consolidation",
"summary", "homework", "blackboard", "rich_text", "exercise",
"text_study", "reflection",
];
if (knownTypes.includes(type)) {
return null;
}
return (
<div className="text-on-surface-variant text-sm p-4">
{t("editor.unknownBlockType")}
</div>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useMemo } from "react";
import { useTranslations } from "next-intl";
import {
ReactFlow,
Background,
@@ -11,48 +12,33 @@ import {
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 { toRfNodes, toRfEdges } from "../lib/rf-mappers";
import type { LessonPlanNode } from "../types";
const nodeTypes = { lesson: LessonNode };
interface Props {
textbookId?: string;
chapterId?: string;
classes?: { id: string; name: string }[];
}
// NodeEditor 只负责画布交互,内容编辑由 NodeEditPanel 处理
type Props = Record<string, never>;
export function NodeEditor({ textbookId, chapterId, classes }: Props) {
export function NodeEditor({}: Props) {
const t = useTranslations("lessonPreparation");
const { doc, selectedNodeId, updateNodePosition, removeNode, connect, selectNode, setEdges } =
useLessonPlanEditor();
// 我们的 nodes → React Flow nodes
// 使用纯函数映射 nodes/edges
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,
})),
() => toRfNodes(doc.nodes, selectedNodeId),
[doc.nodes, selectedNodeId],
);
// edges 直接兼容
const rfEdges: Edge[] = useMemo(
() =>
doc.edges.map((e) => ({
...e,
animated: true,
style: { stroke: "#1976d2", strokeWidth: 2 },
})),
() => toRfEdges(doc.edges),
[doc.edges],
);
@@ -60,15 +46,16 @@ export function NodeEditor({ textbookId, chapterId, classes }: Props) {
(changes: NodeChange[]) => {
changes.forEach((change) => {
if (change.type === "position" && change.position) {
updateNodePosition(change.id, change.position);
// 拖拽结束时dragging: false才写入最终位置避免中间状态污染修复 P1-1
if (change.dragging === false) {
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],
);
@@ -100,7 +87,15 @@ export function NodeEditor({ textbookId, chapterId, classes }: Props) {
);
return (
<div className="w-full h-full">
<div className="w-full h-full relative">
{doc.nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div className="text-center text-on-surface-variant">
<p className="text-lg font-medium">{t("editor.canvasEmpty")}</p>
<p className="text-sm mt-1">{t("editor.canvasEmptyHint")}</p>
</div>
</div>
)}
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
@@ -129,7 +124,8 @@ export function NodeEditor({ textbookId, chapterId, classes }: Props) {
<MiniMap
className="!bg-surface !border-outline-variant"
nodeColor={(n) => {
const data = n.data as unknown as LessonPlanNode;
const nodeData = (n.data as { node?: LessonPlanNode }).node;
if (!nodeData) return "#9e9e9e";
const colors: Record<string, string> = {
objective: "#4caf50",
key_point: "#f44336",
@@ -144,12 +140,10 @@ export function NodeEditor({ textbookId, chapterId, classes }: Props) {
rich_text: "#9e9e9e",
reflection: "#cddc39",
};
return colors[data.type] ?? "#9e9e9e";
return colors[nodeData.type] ?? "#9e9e9e";
}}
/>
</ReactFlow>
{/* 隐藏的 props 传递,避免 unused 警告 */}
<span className="hidden" data-textbook={textbookId} data-chapter={chapterId} data-classes={classes?.length} />
</div>
);
}

View File

@@ -1,53 +1,21 @@
"use client";
import { memo } from "react";
import { useTranslations } from "next-intl";
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 "空";
}
import { getNodeColor, getNodeSummary, type NodeSummaryT } from "../../lib/node-summary";
export const LessonNode = memo(function LessonNode({
data,
selected,
}: NodeProps) {
const nodeData = data as unknown as LessonPlanNode;
const color = NODE_COLORS[nodeData.type] ?? "#9e9e9e";
const t = useTranslations("lessonPreparation");
const nodeData = (data as { node: LessonPlanNode }).node;
const color = getNodeColor(nodeData.type);
// 适配 next-intl 的 t 到 NodeSummaryT 接口
const summaryT: NodeSummaryT = (key, values) => t(key, values);
const summary = getNodeSummary(nodeData, summaryT);
return (
<div
@@ -66,14 +34,14 @@ export const LessonNode = memo(function LessonNode({
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>
<span>{t(`blockType.${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)}
{summary}
</div>
</div>
<Handle

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { publishLessonPlanHomeworkAction } from "../actions-publish";
import { Button } from "@/shared/components/ui/button";
import { X } from "lucide-react";
@@ -20,6 +21,7 @@ export function PublishHomeworkDialog({
onClose,
onPublished,
}: Props) {
const t = useTranslations("lessonPreparation");
const [selectedClasses, setSelectedClasses] = useState<string[]>([]);
const [availableAt, setAvailableAt] = useState("");
const [dueAt, setDueAt] = useState("");
@@ -28,7 +30,7 @@ export function PublishHomeworkDialog({
async function handlePublish() {
if (selectedClasses.length === 0) {
setError("请选择至少一个班级");
setError(t("publish.selectClass"));
return;
}
setLoading(true);
@@ -45,7 +47,7 @@ export function PublishHomeworkDialog({
onPublished();
onClose();
} else {
setError(res.message ?? "发布失败");
setError(res.message ?? t("error.publish"));
}
}
@@ -53,14 +55,14 @@ export function PublishHomeworkDialog({
<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>
<h3 className="font-title-md">{t("publish.title")}</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>
<label className="text-sm font-medium">{t("publish.classLabel")}</label>
<div className="mt-1 space-y-1 max-h-40 overflow-y-auto">
{classes.map((c) => (
<label
@@ -85,7 +87,7 @@ export function PublishHomeworkDialog({
</div>
<div>
<label className="text-sm font-medium">
{t("publish.availableAtLabel")}
</label>
<input
type="datetime-local"
@@ -96,7 +98,7 @@ export function PublishHomeworkDialog({
</div>
<div>
<label className="text-sm font-medium">
{t("publish.dueAtLabel")}
</label>
<input
type="datetime-local"
@@ -109,10 +111,10 @@ export function PublishHomeworkDialog({
</div>
<div className="p-4 border-t flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
{t("action.cancel")}
</Button>
<Button onClick={handlePublish} disabled={loading}>
{loading ? "发布中..." : "发布"}
{loading ? t("publish.publishing") : t("publish.publish")}
</Button>
</div>
</div>

View File

@@ -1,6 +1,7 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useTranslations } from "next-intl"
import { getQuestionsAction } from "@/modules/questions/actions"
import { Button } from "@/shared/components/ui/button"
import { useDebounce } from "@/shared/hooks/use-debounce"
@@ -23,6 +24,7 @@ interface Props {
}
export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
const t = useTranslations("lessonPreparation")
const [questions, setQuestions] = useState<QuestionRow[]>([])
const [picked, setPicked] = useState<ExerciseItem[]>([])
@@ -92,7 +94,7 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
<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>
<h3 className="font-title-md">{t("questionBank.title")}</h3>
<button onClick={onClose}>
<X className="w-4 h-4" />
</button>
@@ -117,24 +119,24 @@ export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
>
<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}
{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"> {picked.length} </span>
<span className="text-sm">{t("questionBank.selected", { count: picked.length })}</span>
<Button
onClick={() => {
onPick(picked)
onClose()
}}
>
{t("questionBank.insert")}
</Button>
</div>
</div>

View File

@@ -1,12 +1,14 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
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 t = useTranslations("lessonPreparation");
const router = useRouter();
const [selected, setSelected] = useState<string>("");
const [title, setTitle] = useState("");
@@ -20,41 +22,41 @@ export function TemplatePicker() {
if (res.success && res.data) {
router.push(`/teacher/lesson-plans/${res.data.planId}/edit`);
} else {
setError(res.message ?? "创建失败");
setError(res.message ?? t("error.createFailed"));
}
}
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>
<label className="font-title-md block mb-2">{t("template.titleLabel")}</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="例如:《秋天》第一课时"
placeholder={t("template.titlePlaceholder")}
/>
</div>
<div>
<label className="font-title-md block mb-2"></label>
<label className="font-title-md block mb-2">{t("template.selectLabel")}</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{SYSTEM_TEMPLATES.map((t) => (
{SYSTEM_TEMPLATES.map((tpl) => (
<button
type="button"
key={t.id}
onClick={() => setSelected(t.id)}
key={tpl.id}
onClick={() => setSelected(tpl.id)}
className={`text-left p-4 border-2 rounded-lg transition-colors ${
selected === t.id
selected === tpl.id
? "border-primary bg-primary/5"
: "border-outline-variant hover:border-primary/50"
}`}
>
<div className="font-title-md">{t.name}</div>
<div className="font-title-md">{tpl.name}</div>
<div className="text-sm text-on-surface-variant mt-1">
{t.blocks.length === 0
? "从空白开始"
: `${t.blocks.length} 个环节`}
{tpl.blocks.length === 0
? t("template.blankHint")
: t("template.blockCount", { count: tpl.blocks.length })}
</div>
</button>
))}
@@ -62,7 +64,7 @@ export function TemplatePicker() {
</div>
{error && <p className="text-error text-sm">{error}</p>}
<Button type="submit" disabled={!selected || !title}>
{t("action.create")}
</Button>
</form>
);

View File

@@ -1,11 +1,13 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import {
getLessonPlanVersionsAction,
revertLessonPlanVersionAction,
} from "../actions";
import { Button } from "@/shared/components/ui/button";
import { formatDateTime } from "@/shared/lib/utils";
import type { LessonPlanVersion } from "../types";
interface Props {
@@ -21,6 +23,7 @@ export function VersionHistoryDrawer({
planId,
onReverted,
}: Props) {
const t = useTranslations("lessonPreparation");
const [versions, setVersions] = useState<LessonPlanVersion[]>([]);
const [loading, setLoading] = useState(false);
@@ -43,7 +46,7 @@ export function VersionHistoryDrawer({
}, [open, planId]);
async function handleRevert(versionNo: number) {
if (!confirm(`确认回退到 v${versionNo}?将生成新版本。`)) return;
if (!confirm(t("version.revertConfirm", { versionNo }))) return;
const res = await revertLessonPlanVersionAction({ planId, versionNo });
if (res.success) {
onReverted();
@@ -58,11 +61,11 @@ export function VersionHistoryDrawer({
<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>
<h3 className="font-headline-md text-headline-md mb-4">{t("version.title")}</h3>
{loading ? (
<p>...</p>
<p>{t("version.loading")}</p>
) : versions.length === 0 ? (
<p className="text-on-surface-variant"></p>
<p className="text-on-surface-variant">{t("version.empty")}</p>
) : (
<div className="flex flex-col gap-2">
{versions.map((v) => (
@@ -74,15 +77,15 @@ export function VersionHistoryDrawer({
<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">
{t("version.auto")}
</span>
)}
</div>
<p className="text-sm text-on-surface-variant">
{v.label ?? "手动保存"}
{v.label ?? t("version.manual")}
</p>
<p className="text-xs text-on-surface-variant mt-1">
{new Date(v.createdAt).toLocaleString()}
{formatDateTime(v.createdAt)}
</p>
<Button
variant="outline"
@@ -90,7 +93,7 @@ export function VersionHistoryDrawer({
className="mt-2"
onClick={() => handleRevert(v.versionNo)}
>
退
{t("version.revert")}
</Button>
</div>
))}

View File

@@ -0,0 +1,110 @@
import type { ReactElement } from "react";
import type {
BlockType,
ExerciseBlockData,
RichTextBlockData,
TextStudyBlockData,
} from "../types";
import { RichTextBlock } from "../components/blocks/rich-text-block";
import { ExerciseBlock } from "../components/blocks/exercise-block";
import { TextStudyBlock } from "../components/blocks/text-study-block";
import { ReflectionBlock } from "../components/blocks/reflection-block";
/**
* Block 注册表:配置驱动渲染。
* 新增 Block 类型只需在此注册,无需修改 NodeEditPanel。
*/
export interface BlockRenderProps {
blockId: string;
data: RichTextBlockData | ExerciseBlockData | TextStudyBlockData;
textbookId?: string;
chapterId?: string;
classes?: { id: string; name: string }[];
onUpdate: (data: RichTextBlockData | ExerciseBlockData | TextStudyBlockData) => void;
}
export interface BlockRegistryEntry {
/** 是否为富文本类(共享 RichTextBlock 编辑器) */
isRichText?: boolean;
}
const RICH_TEXT_TYPES: BlockType[] = [
"objective",
"key_point",
"import",
"new_teaching",
"consolidation",
"summary",
"homework",
"blackboard",
"rich_text",
];
/**
* Block 注册表元数据(用于查询 isRichText 等属性)。
* 组件渲染由 BlockRenderer 统一处理,避免在 render 中动态获取组件引用。
*/
export const BLOCK_REGISTRY: Record<BlockType, BlockRegistryEntry> = {
objective: { isRichText: true },
key_point: { isRichText: true },
import: { isRichText: true },
new_teaching: { isRichText: true },
consolidation: { isRichText: true },
summary: { isRichText: true },
homework: { isRichText: true },
blackboard: { isRichText: true },
rich_text: { isRichText: true },
exercise: {},
text_study: {},
reflection: {},
};
export function isRichTextBlock(type: BlockType): boolean {
return RICH_TEXT_TYPES.includes(type);
}
/**
* 静态 Block 渲染组件。
* 根据 type 从注册表查找并渲染对应 Block所有组件引用均为模块顶层静态声明
* 满足 react-hooks/static-components 规则。
* 新增 Block 类型时,在此 switch 中添加对应 case 即可。
*/
export function BlockRenderer(props: BlockRenderProps & { type: BlockType }): ReactElement | null {
const { type, ...rest } = props;
switch (type) {
case "exercise":
return (
<ExerciseBlock
blockId={rest.blockId}
data={rest.data as ExerciseBlockData}
classes={rest.classes ?? []}
textbookId={rest.textbookId}
chapterId={rest.chapterId}
/>
);
case "text_study":
return <TextStudyBlock blockId={rest.blockId} data={rest.data as TextStudyBlockData} />;
case "reflection":
return <ReflectionBlock data={rest.data as RichTextBlockData} onUpdate={rest.onUpdate} />;
case "objective":
case "key_point":
case "import":
case "new_teaching":
case "consolidation":
case "summary":
case "homework":
case "blackboard":
case "rich_text":
return (
<RichTextBlock
data={rest.data as RichTextBlockData}
textbookId={rest.textbookId}
chapterId={rest.chapterId}
onUpdate={rest.onUpdate}
/>
);
default:
return null;
}
}

View File

@@ -16,78 +16,20 @@ import {
} from "@/shared/db/schema";
import type { DataScope } from "@/shared/types/permissions";
import { SYSTEM_TEMPLATES } from "./constants";
import {
migrateV1ToV2,
normalizeDocument,
buildInitialContent,
} from "./lib/document-migration";
import type {
LessonPlan,
LessonPlanDocument,
LessonPlanDocumentV1,
LessonPlanEdge,
LessonPlanListItem,
LessonPlanNode,
LessonPlanTemplate,
TemplateBlockSkeleton,
} from "./types";
// ---- v1 → v2 迁移:将旧 blocks 数组转换为 nodes + 线性 edges ----
export function migrateV1ToV2(doc: LessonPlanDocumentV1): LessonPlanDocument {
const nodes: LessonPlanNode[] = doc.blocks.map((b, i) => ({
...b,
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
}));
const edges: LessonPlanEdge[] = [];
for (let i = 0; i < nodes.length - 1; i++) {
edges.push({
id: `e_${nodes[i].id}_${nodes[i + 1].id}`,
source: nodes[i].id,
target: nodes[i + 1].id,
});
}
return { version: 2, nodes, edges };
}
// ---- 规范化:确保 content 是 v2 格式(兼容旧数据)----
export function normalizeDocument(
content: unknown,
): LessonPlanDocument {
if (content && typeof content === "object") {
const c = content as { version?: number };
if (c.version === 2) {
return content as LessonPlanDocument;
}
if (c.version === 1) {
return migrateV1ToV2(content as LessonPlanDocumentV1);
}
}
// 空文档
return { version: 2, nodes: [], edges: [] };
}
// ---- 模板初始化:根据骨架生成初始 contentv2----
export function buildInitialContent(
blocks: TemplateBlockSkeleton[],
): LessonPlanDocument {
const nodes: LessonPlanNode[] = blocks.map((b, i) => ({
id: createId(),
type: b.type,
title: b.title,
data:
b.type === "exercise"
? { items: [], purpose: "class_practice", knowledgePointIds: [] }
: b.type === "text_study"
? { sourceText: "", annotations: [], knowledgePointIds: [] }
: { html: "", knowledgePointIds: [] },
order: i,
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
}));
const edges: LessonPlanEdge[] = [];
for (let i = 0; i < nodes.length - 1; i++) {
edges.push({
id: `e_${nodes[i].id}_${nodes[i + 1].id}`,
source: nodes[i].id,
target: nodes[i + 1].id,
});
}
return { version: 2, nodes, edges };
}
// re-export 纯函数保持向后兼容
export { migrateV1ToV2, normalizeDocument, buildInitialContent };
// ---- DataScope → 查询条件 ----
function buildScopeCondition(scope: DataScope, userId: string): SQL[] {

View File

@@ -0,0 +1,86 @@
import { createId } from "@paralleldrive/cuid2";
import type {
LessonPlanDocument,
LessonPlanDocumentV1,
LessonPlanEdge,
LessonPlanNode,
TemplateBlockSkeleton,
} from "../types";
/**
* 纯函数模块:课案文档迁移、规范化、初始内容构建。
* 从 data-access.ts 抽取,便于单元测试。
*/
// ---- v1 → v2 迁移:将旧 blocks 数组转换为 nodes + 线性 edges ----
export function migrateV1ToV2(doc: LessonPlanDocumentV1): LessonPlanDocument {
const nodes: LessonPlanNode[] = doc.blocks.map((b, i) => ({
...b,
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
}));
const edges: LessonPlanEdge[] = [];
for (let i = 0; i < nodes.length - 1; i++) {
edges.push({
id: `e_${nodes[i].id}_${nodes[i + 1].id}`,
source: nodes[i].id,
target: nodes[i + 1].id,
});
}
return { version: 2, nodes, edges };
}
// ---- 类型守卫:判断是否为 v2 文档 ----
function isV2Document(content: unknown): content is LessonPlanDocument {
if (!content || typeof content !== "object") return false;
const c = content as { version?: unknown; nodes?: unknown; edges?: unknown };
return (
c.version === 2 &&
Array.isArray(c.nodes) &&
Array.isArray(c.edges)
);
}
// ---- 类型守卫:判断是否为 v1 文档 ----
function isV1Document(content: unknown): content is LessonPlanDocumentV1 {
if (!content || typeof content !== "object") return false;
const c = content as { version?: unknown; blocks?: unknown };
return c.version === 1 && Array.isArray(c.blocks);
}
// ---- 规范化:确保 content 是 v2 格式(兼容旧数据)----
export function normalizeDocument(
content: unknown,
): LessonPlanDocument {
if (isV2Document(content)) return content;
if (isV1Document(content)) return migrateV1ToV2(content);
// 空文档
return { version: 2, nodes: [], edges: [] };
}
// ---- 模板初始化:根据骨架生成初始 contentv2----
export function buildInitialContent(
blocks: TemplateBlockSkeleton[],
): LessonPlanDocument {
const nodes: LessonPlanNode[] = blocks.map((b, i) => ({
id: createId(),
type: b.type,
title: b.title,
data:
b.type === "exercise"
? { items: [], purpose: "class_practice", knowledgePointIds: [] }
: b.type === "text_study"
? { sourceText: "", annotations: [], knowledgePointIds: [] }
: { html: "", knowledgePointIds: [] },
order: i,
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
}));
const edges: LessonPlanEdge[] = [];
for (let i = 0; i < nodes.length - 1; i++) {
edges.push({
id: `e_${nodes[i].id}_${nodes[i + 1].id}`,
source: nodes[i].id,
target: nodes[i + 1].id,
});
}
return { version: 2, nodes, edges };
}

View File

@@ -0,0 +1,59 @@
import type { LessonPlanNode } from "../types";
/**
* 节点摘要翻译函数接口。
* 调用方传入 next-intl 的 t 函数,避免纯函数直接耦合 i18n 实现。
* values 类型对齐 next-intl 的 TranslationValuesstring | number | Date
*/
export interface NodeSummaryT {
(key: "editor.questionCount" | "editor.charCount" | "editor.nodeSummaryEmpty", values?: Record<string, string | number | Date>): string;
}
/**
* 纯函数:获取节点摘要文本(用于节点卡片显示)。
* 从 lesson-node.tsx 抽取,便于单元测试。
* 翻译文本由调用方通过 t 函数注入,保证纯函数可测性。
*/
export function getNodeSummary(node: LessonPlanNode, t: NodeSummaryT): string {
const data = node.data as {
html?: string;
sourceText?: string;
items?: unknown[];
knowledgePointIds?: string[];
};
if (data.items !== undefined) {
return t("editor.questionCount", { count: data.items.length });
}
if (data.sourceText !== undefined && data.sourceText) {
return t("editor.charCount", { count: data.sourceText.length });
}
if (data.html) {
// 去标签后取前 40 字
const text = data.html.replace(/<[^>]+>/g, "").trim();
return text.slice(0, 40) || t("editor.nodeSummaryEmpty");
}
return t("editor.nodeSummaryEmpty");
}
/**
* 节点类型 → 图标颜色Material Design 色板)。
* 供 lesson-node 和 minimap 复用。
*/
export 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",
};
export function getNodeColor(type: string): string {
return NODE_COLORS[type] ?? "#9e9e9e";
}

View File

@@ -0,0 +1,43 @@
import type { Node, Edge } from "@xyflow/react";
import type { LessonPlanNode, LessonPlanEdge } from "../types";
/**
* 纯函数:将课案 nodes/edges 映射为 React Flow 格式。
* 从 node-editor.tsx 抽取,便于单元测试。
*/
export function toRfNodes(
nodes: LessonPlanNode[],
selectedNodeId: string | null,
): Node[] {
return nodes.map((n) => ({
id: n.id,
type: "lesson",
position: n.position,
data: { node: n } as Record<string, unknown>,
selected: n.id === selectedNodeId,
}));
}
export function toRfEdges(edges: LessonPlanEdge[]): Edge[] {
return edges.map((e) => ({
...e,
animated: true,
style: { stroke: "#1976d2", strokeWidth: 2 },
}));
}
/**
* 将 React Flow edges 转回课案 edges 格式。
*/
export function fromRfEdges(
rfEdges: Edge[],
): LessonPlanEdge[] {
return rfEdges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle ?? null,
targetHandle: e.targetHandle ?? null,
}));
}

View File

@@ -4,13 +4,11 @@ import { eq } from "drizzle-orm";
import { createId } from "@paralleldrive/cuid2";
import { db } from "@/shared/db";
import {
lessonPlans,
examQuestions,
} from "@/shared/db/schema";
import { lessonPlans } from "@/shared/db/schema";
import { createQuestionWithRelations } from "@/modules/questions/data-access";
import { persistExamDraft } from "@/modules/exams/data-access";
import { persistExamDraft, addExamQuestions } from "@/modules/exams/data-access";
import { createHomeworkAssignment } from "@/modules/homework/data-access-write";
import { getStudentIdsByClassIds } from "@/modules/classes/data-access";
import { normalizeDocument } from "./data-access";
import type { LessonPlanDocument, ExerciseBlockData } from "./types";
@@ -29,20 +27,6 @@ interface PublishResult {
updatedContent: LessonPlanDocument;
}
// 查询班级学生列表(避免直接依赖 classes 模块的内部表)
async function getStudentIdsByClassIds(
classIds: string[],
): Promise<string[]> {
if (classIds.length === 0) return [];
const { inArray } = await import("drizzle-orm");
const { classEnrollments } = await import("@/shared/db/schema");
const rows = await db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(inArray(classEnrollments.classId, classIds));
return rows.map((r) => r.studentId);
}
export async function publishLessonPlanHomework(
input: PublishInput,
): Promise<PublishResult> {
@@ -80,9 +64,7 @@ export async function publishLessonPlanHomework(
throw new Error("该练习块已发布,请使用'重新发布'");
// 3. inline 题目入库,替换占位 ID
const newContent: LessonPlanDocument = JSON.parse(
JSON.stringify(plan.content),
);
const newContent: LessonPlanDocument = structuredClone(plan.content);
const newBlock = newContent.nodes.find((b) => b.id === input.blockId);
if (!newBlock || newBlock.type !== "exercise")
throw new Error("练习块不存在");
@@ -122,17 +104,15 @@ export async function publishLessonPlanHomework(
scheduledAt: undefined,
description: `来自课案:${plan.title}`,
});
// 插入 examQuestions
if (newData.items.length > 0) {
await db.insert(examQuestions).values(
newData.items.map((it, i) => ({
examId,
questionId: it.questionId,
score: it.score,
order: i,
})),
);
}
// 插入 examQuestions(通过 exams data-access 跨模块接口)
await addExamQuestions(
examId,
newData.items.map((it, i) => ({
questionId: it.questionId,
score: it.score,
order: i,
})),
);
// 5. 下发作业
const assignmentId = createId();