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>