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:
86
src/modules/lesson-preparation/lib/document-migration.ts
Normal file
86
src/modules/lesson-preparation/lib/document-migration.ts
Normal 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: [] };
|
||||
}
|
||||
|
||||
// ---- 模板初始化:根据骨架生成初始 content(v2)----
|
||||
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 };
|
||||
}
|
||||
59
src/modules/lesson-preparation/lib/node-summary.ts
Normal file
59
src/modules/lesson-preparation/lib/node-summary.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { LessonPlanNode } from "../types";
|
||||
|
||||
/**
|
||||
* 节点摘要翻译函数接口。
|
||||
* 调用方传入 next-intl 的 t 函数,避免纯函数直接耦合 i18n 实现。
|
||||
* values 类型对齐 next-intl 的 TranslationValues(string | 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";
|
||||
}
|
||||
43
src/modules/lesson-preparation/lib/rf-mappers.ts
Normal file
43
src/modules/lesson-preparation/lib/rf-mappers.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user