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>
))}