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:
@@ -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>
|
||||
|
||||
@@ -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")} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user