feat(lesson-preparation): add readonly view, anchor node selector, and type guards
- Add lesson-plan-readonly-view for viewing published plans - Add anchor-node-selector and textbook-segments for canvas anchor positioning - Add i18n-errors and type-guards lib utilities - Add lesson-plan-provider-setup for provider initialization - Update actions, data-access (knowledge, versions, main), publish-service - Update blocks (blackboard, exercise, homework, import, key-point, objective, reflection) - Update editor, node-editor, node-edit-panel, pickers, and providers
This commit is contained in:
@@ -226,28 +226,29 @@ export function buildDefaultSkeleton(
|
||||
translateTitle?: (key: string) => string,
|
||||
): LessonPlanDocument {
|
||||
const textbookContentNodeId = createId();
|
||||
// P2-5:正文节点居中,左右列增大间距避免重叠
|
||||
const textbookNode: TextbookContentNode = {
|
||||
id: textbookContentNodeId,
|
||||
type: "textbook_content",
|
||||
title: "textbook_content",
|
||||
data: { chapterId, content: chapterContent, zoom: 1 },
|
||||
order: -1,
|
||||
position: { x: 400, y: 200 },
|
||||
position: { x: 500, y: 250 },
|
||||
draggable: false,
|
||||
};
|
||||
|
||||
// 默认 10 节点骨架(标题使用 i18n 键 blockType.${type})
|
||||
// P2-5:左列 x=80,右列 x=900,避免与正文节点(宽 480)重叠
|
||||
const skeleton: { type: BlockType; position: { x: number; y: number } }[] = [
|
||||
{ type: "objective", position: { x: 80, y: 80 } },
|
||||
{ type: "key_point", position: { x: 80, y: 200 } },
|
||||
{ type: "import", position: { x: 80, y: 320 } },
|
||||
{ type: "text_study", position: { x: 80, y: 440 } },
|
||||
{ type: "new_teaching", position: { x: 720, y: 80 } },
|
||||
{ type: "exercise", position: { x: 720, y: 200 } },
|
||||
{ type: "summary", position: { x: 720, y: 320 } },
|
||||
{ type: "new_teaching", position: { x: 900, y: 80 } },
|
||||
{ type: "exercise", position: { x: 900, y: 200 } },
|
||||
{ type: "summary", position: { x: 900, y: 320 } },
|
||||
{ type: "homework", position: { x: 80, y: 560 } },
|
||||
{ type: "blackboard", position: { x: 720, y: 440 } },
|
||||
{ type: "reflection", position: { x: 720, y: 560 } },
|
||||
{ type: "blackboard", position: { x: 900, y: 440 } },
|
||||
{ type: "reflection", position: { x: 900, y: 560 } },
|
||||
];
|
||||
|
||||
const nodes: LessonPlanNode[] = skeleton.map((s, i) => ({
|
||||
|
||||
50
src/modules/lesson-preparation/lib/i18n-errors.ts
Normal file
50
src/modules/lesson-preparation/lib/i18n-errors.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import "server-only";
|
||||
import type { z } from "zod";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
/**
|
||||
* 将 Zod 校验失败的 fieldErrors 中的 i18n 键翻译为实际消息。
|
||||
*
|
||||
* schema.ts 中错误消息存储为 i18n 键(如 "error.titleRequired"),
|
||||
* 此函数在 actions 层调用,将键翻译为当前语言的文本。
|
||||
*
|
||||
* 返回类型为 Record<string, string[]>(不含 undefined),
|
||||
* 因为只有非 undefined 的字段才会被加入结果。
|
||||
*/
|
||||
export async function translateFieldErrors(
|
||||
errors: Record<string, string[] | undefined>,
|
||||
): Promise<Record<string, string[]>> {
|
||||
const t = await getTranslations("lessonPreparation");
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const [field, messages] of Object.entries(errors)) {
|
||||
if (!messages) continue;
|
||||
result[field] = messages.map((msg) => {
|
||||
// 仅翻译以 "error." 开头的 i18n 键,其他保持原样
|
||||
if (msg.startsWith("error.")) {
|
||||
return t(msg as Parameters<typeof t>[0]);
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全解析 Zod 结果并返回 ActionState 错误格式(带 i18n 翻译)。
|
||||
* 若校验失败,返回翻译后的 fieldErrors;若成功,返回 parsed.data。
|
||||
*/
|
||||
export async function safeParseWithI18n<T>(
|
||||
schema: z.ZodType<T>,
|
||||
input: unknown,
|
||||
): Promise<
|
||||
| { success: true; data: T }
|
||||
| { success: false; errors: Record<string, string[]> }
|
||||
> {
|
||||
const result = schema.safeParse(input);
|
||||
if (!result.success) {
|
||||
const errors = result.error.flatten().fieldErrors;
|
||||
const translated = await translateFieldErrors(errors);
|
||||
return { success: false, errors: translated };
|
||||
}
|
||||
return { success: true, data: result.data };
|
||||
}
|
||||
@@ -2,10 +2,9 @@ import type { Node, Edge } from "@xyflow/react";
|
||||
import type {
|
||||
AnyLessonPlanEdge,
|
||||
AnyLessonPlanNode,
|
||||
LessonPlanNode,
|
||||
NodeAnchor,
|
||||
TextbookContentNode,
|
||||
} from "../types";
|
||||
import { getNodeColor } from "./node-summary";
|
||||
|
||||
/**
|
||||
* 纯函数:将课案 nodes/edges 映射为 React Flow 格式。
|
||||
@@ -20,6 +19,8 @@ import type {
|
||||
export interface ToRfNodesContext {
|
||||
anchors: NodeAnchor[];
|
||||
selectedNodeId: string | null;
|
||||
/** 可锚定的教学节点列表(P1-1:用于节点选择器)*/
|
||||
anchorableNodes?: { id: string; title: string; type: string }[];
|
||||
onAddRangeAnchor?: (params: {
|
||||
nodeId: string;
|
||||
start: number;
|
||||
@@ -30,8 +31,14 @@ export interface ToRfNodesContext {
|
||||
nodeId: string;
|
||||
start: number;
|
||||
}) => void;
|
||||
/** 创建新节点并锚定(P1-1)*/
|
||||
onCreateNewNode?: (params: {
|
||||
anchorType: "range" | "point";
|
||||
start: number;
|
||||
end?: number;
|
||||
textPreview?: string;
|
||||
}) => void;
|
||||
onSelectNode?: (id: string | null) => void;
|
||||
onZoomChange?: (zoom: number) => void;
|
||||
}
|
||||
|
||||
export function toRfNodes(
|
||||
@@ -39,10 +46,25 @@ export function toRfNodes(
|
||||
selectedNodeId: string | null,
|
||||
ctx?: ToRfNodesContext,
|
||||
): Node[] {
|
||||
// 当有选中节点时,收集所有与选中节点相关的节点 ID(通过锚点关联)
|
||||
const relatedNodeIds = new Set<string>();
|
||||
if (selectedNodeId && ctx?.anchors) {
|
||||
for (const a of ctx.anchors) {
|
||||
if (a.nodeId === selectedNodeId) {
|
||||
relatedNodeIds.add(a.nodeId);
|
||||
}
|
||||
}
|
||||
// 正文节点始终相关(因为锚点在正文上)
|
||||
const textbookNode = nodes.find((n) => n.type === "textbook_content");
|
||||
if (textbookNode) relatedNodeIds.add(textbookNode.id);
|
||||
relatedNodeIds.add(selectedNodeId);
|
||||
}
|
||||
|
||||
return nodes.map((n) => {
|
||||
// 正文节点
|
||||
// 正文节点:n.type === "textbook_content" 已收窄为 TextbookContentNode
|
||||
if (n.type === "textbook_content") {
|
||||
const tbNode = n as TextbookContentNode;
|
||||
const tbNode = n;
|
||||
const isDimmed = selectedNodeId !== null && !relatedNodeIds.has(tbNode.id);
|
||||
return {
|
||||
id: tbNode.id,
|
||||
type: "textbook_content",
|
||||
@@ -51,24 +73,28 @@ export function toRfNodes(
|
||||
node: tbNode,
|
||||
anchors: ctx?.anchors ?? [],
|
||||
selectedNodeId,
|
||||
anchorableNodes: ctx?.anchorableNodes ?? [],
|
||||
onAddRangeAnchor: ctx?.onAddRangeAnchor,
|
||||
onAddPointAnchor: ctx?.onAddPointAnchor,
|
||||
onCreateNewNode: ctx?.onCreateNewNode,
|
||||
onSelectNode: ctx?.onSelectNode,
|
||||
onZoomChange: ctx?.onZoomChange,
|
||||
} as Record<string, unknown>,
|
||||
selected: tbNode.id === selectedNodeId,
|
||||
draggable: false,
|
||||
style: isDimmed ? { opacity: 0.3 } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// 教学节点
|
||||
const lessonNode = n as LessonPlanNode;
|
||||
// 教学节点:textbook_content 分支已上方 return,此处 n 已收窄为 LessonPlanNode
|
||||
const lessonNode = n;
|
||||
const isDimmed = selectedNodeId !== null && !relatedNodeIds.has(lessonNode.id);
|
||||
return {
|
||||
id: lessonNode.id,
|
||||
type: "lesson",
|
||||
position: lessonNode.position,
|
||||
data: { node: lessonNode } as Record<string, unknown>,
|
||||
selected: lessonNode.id === selectedNodeId,
|
||||
style: isDimmed ? { opacity: 0.3 } : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -80,36 +106,39 @@ export function toRfEdges(
|
||||
): Edge[] {
|
||||
return edges.map((e) => {
|
||||
if (e.type === "anchor") {
|
||||
// 锚点边:默认 10% 透明度,选中关联节点时 100%
|
||||
// 锚点边:默认 40% 透明度,选中关联节点时 100%
|
||||
const anchor = anchors.find((a) => a.id === e.anchorId);
|
||||
const isActive = anchor && anchor.nodeId === selectedNodeId;
|
||||
// P1-4 修复:使用锚点关联节点的颜色,而非硬编码蓝色
|
||||
const strokeColor = anchor ? getNodeColor(anchor.nodeId) : "#9e9e9e";
|
||||
return {
|
||||
...e,
|
||||
animated: false,
|
||||
animated: isActive,
|
||||
className: isActive ? "anchor-edge active" : "anchor-edge",
|
||||
// P1-3 修复:将 anchorId 存入 data,fromRfEdges 从 data 读取
|
||||
data: { anchorId: e.anchorId },
|
||||
style: {
|
||||
stroke: anchor ? getNodeColorForAnchor(anchor.nodeId) : "#9e9e9e",
|
||||
strokeWidth: 2,
|
||||
opacity: isActive ? 1 : 0.1,
|
||||
stroke: strokeColor,
|
||||
strokeWidth: isActive ? 3 : 2,
|
||||
opacity: isActive ? 1 : 0.4,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 流程边
|
||||
const isDimmed = selectedNodeId !== null && e.source !== selectedNodeId && e.target !== selectedNodeId;
|
||||
return {
|
||||
...e,
|
||||
animated: true,
|
||||
style: { stroke: "#1976d2", strokeWidth: 2 },
|
||||
animated: !isDimmed,
|
||||
style: {
|
||||
stroke: "#1976d2",
|
||||
strokeWidth: 2,
|
||||
opacity: isDimmed ? 0.2 : 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 简单的颜色查找(避免循环依赖 node-summary)
|
||||
function getNodeColorForAnchor(_nodeId: string): string {
|
||||
// 实际颜色由 CSS 类 .anchor-edge 设置,这里返回默认值
|
||||
return "#1976d2";
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 React Flow edges 转回课案 edges 格式。
|
||||
*/
|
||||
@@ -125,12 +154,13 @@ export function fromRfEdges(
|
||||
targetHandle: e.targetHandle ?? null,
|
||||
};
|
||||
|
||||
// 保留原有的 type 信息(通过 className 判断或默认为 flow)
|
||||
if (e.className?.includes("anchor-edge")) {
|
||||
// P1-3 修复:优先从 data.anchorId 读取,回退到 className 判断
|
||||
const dataAnchorId = (e.data as { anchorId?: string } | undefined)?.anchorId;
|
||||
if (dataAnchorId || e.className?.includes("anchor-edge")) {
|
||||
return {
|
||||
...base,
|
||||
type: "anchor" as const,
|
||||
anchorId: e.id, // 简化:用 edge id 作为 anchorId(实际应从 data 读取)
|
||||
anchorId: dataAnchorId ?? e.id,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
213
src/modules/lesson-preparation/lib/type-guards.ts
Normal file
213
src/modules/lesson-preparation/lib/type-guards.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
// 备课模块集中类型守卫:替代 `as` 断言,安全收窄 unknown 联合类型
|
||||
import type {
|
||||
BlackboardBlockData,
|
||||
BlockData,
|
||||
BlockType,
|
||||
ExerciseBlockData,
|
||||
ExercisePurpose,
|
||||
HomeworkAssignment,
|
||||
HomeworkBlockData,
|
||||
ImportBlockData,
|
||||
KeyPointBlockData,
|
||||
KeyPointItem,
|
||||
LessonPlanNode,
|
||||
LessonPlanStatus,
|
||||
NewTeachingBlockData,
|
||||
ObjectiveBlockData,
|
||||
ObjectiveItem,
|
||||
ReflectionBlockData,
|
||||
ReflectionItem,
|
||||
RichTextBlockData,
|
||||
SummaryBlockData,
|
||||
TemplateScope,
|
||||
TemplateType,
|
||||
TextStudyBlockData,
|
||||
TextbookContentNode,
|
||||
} from "../types";
|
||||
|
||||
// ---- 基础类型守卫 ----
|
||||
const LESSON_PLAN_STATUSES = ["draft", "published", "archived"] as const;
|
||||
export function isLessonPlanStatus(v: string): v is LessonPlanStatus {
|
||||
return (LESSON_PLAN_STATUSES as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
const TEMPLATE_TYPES = ["system", "personal"] as const;
|
||||
export function isTemplateType(v: string): v is TemplateType {
|
||||
return (TEMPLATE_TYPES as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
const TEMPLATE_SCOPES = [
|
||||
"regular",
|
||||
"review",
|
||||
"experiment",
|
||||
"inquiry",
|
||||
"blank",
|
||||
"custom",
|
||||
] as const;
|
||||
export function isTemplateScope(v: string): v is TemplateScope {
|
||||
return (TEMPLATE_SCOPES as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
// ---- Block 数据类型守卫 ----
|
||||
// 各守卫通过检查该 Block 数据接口的"特征字段"来收窄联合类型 BlockData。
|
||||
// 特征字段选取接口中独有且必填的字段,避免与其他接口混淆。
|
||||
|
||||
function isObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null;
|
||||
}
|
||||
|
||||
export function isRichTextBlockData(data: BlockData): data is RichTextBlockData {
|
||||
return isObject(data) && typeof data.html === "string" && Array.isArray(data.knowledgePointIds);
|
||||
}
|
||||
|
||||
export function isTextStudyBlockData(data: BlockData): data is TextStudyBlockData {
|
||||
return (
|
||||
isObject(data) &&
|
||||
typeof data.sourceText === "string" &&
|
||||
Array.isArray(data.annotations) &&
|
||||
Array.isArray(data.knowledgePointIds)
|
||||
);
|
||||
}
|
||||
|
||||
export function isExerciseBlockData(data: BlockData): data is ExerciseBlockData {
|
||||
return (
|
||||
isObject(data) &&
|
||||
Array.isArray(data.items) &&
|
||||
(data.purpose === "class_practice" || data.purpose === "after_class_homework")
|
||||
);
|
||||
}
|
||||
|
||||
export function isObjectiveBlockData(data: BlockData): data is ObjectiveBlockData {
|
||||
return isObject(data) && Array.isArray(data.objectives);
|
||||
}
|
||||
|
||||
export function isKeyPointBlockData(data: BlockData): data is KeyPointBlockData {
|
||||
return isObject(data) && Array.isArray(data.keyPoints);
|
||||
}
|
||||
|
||||
export function isImportBlockData(data: BlockData): data is ImportBlockData {
|
||||
return (
|
||||
isObject(data) &&
|
||||
typeof data.prompt === "string" &&
|
||||
typeof data.durationMin === "number" &&
|
||||
typeof data.method === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export function isNewTeachingBlockData(data: BlockData): data is NewTeachingBlockData {
|
||||
return isObject(data) && Array.isArray(data.teachingPoints);
|
||||
}
|
||||
|
||||
export function isSummaryBlockData(data: BlockData): data is SummaryBlockData {
|
||||
return isObject(data) && Array.isArray(data.summaryPoints) && typeof data.homeworkPreview === "string";
|
||||
}
|
||||
|
||||
export function isHomeworkBlockData(data: BlockData): data is HomeworkBlockData {
|
||||
return isObject(data) && Array.isArray(data.assignments);
|
||||
}
|
||||
|
||||
export function isBlackboardBlockData(data: BlockData): data is BlackboardBlockData {
|
||||
return (
|
||||
isObject(data) &&
|
||||
typeof data.layout === "string" &&
|
||||
typeof data.content === "string" &&
|
||||
Array.isArray(data.knowledgePointIds)
|
||||
);
|
||||
}
|
||||
|
||||
export function isReflectionBlockData(data: BlockData): data is ReflectionBlockData {
|
||||
return isObject(data) && Array.isArray(data.reflection);
|
||||
}
|
||||
|
||||
// ---- Block 字段值类型守卫(用于 select onChange 等场景,替代 `as` 断言)----
|
||||
|
||||
const BLACKBOARD_LAYOUTS = ["structure", "mindmap", "text"] as const;
|
||||
export function isBlackboardLayout(
|
||||
v: string,
|
||||
): v is BlackboardBlockData["layout"] {
|
||||
return (BLACKBOARD_LAYOUTS as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
const IMPORT_METHODS = ["question", "situation", "review", "other"] as const;
|
||||
export function isImportMethod(v: string): v is ImportBlockData["method"] {
|
||||
return (IMPORT_METHODS as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
const EXERCISE_PURPOSES = ["class_practice", "after_class_homework"] as const;
|
||||
export function isExercisePurpose(v: string): v is ExercisePurpose {
|
||||
return (EXERCISE_PURPOSES as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
const OBJECTIVE_DIMENSIONS = ["knowledge", "process", "emotion"] as const;
|
||||
export function isObjectiveDimension(
|
||||
v: string,
|
||||
): v is ObjectiveItem["dimension"] {
|
||||
return (OBJECTIVE_DIMENSIONS as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
const KEY_POINT_TYPES = ["key", "difficult"] as const;
|
||||
export function isKeyPointType(v: string): v is KeyPointItem["type"] {
|
||||
return (KEY_POINT_TYPES as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
const HOMEWORK_TYPES = ["exercise", "reading", "writing"] as const;
|
||||
export function isHomeworkType(v: string): v is HomeworkAssignment["type"] {
|
||||
return (HOMEWORK_TYPES as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
const REFLECTION_ASPECTS = [
|
||||
"effectiveness",
|
||||
"problems",
|
||||
"improvements",
|
||||
] as const;
|
||||
export function isReflectionAspect(
|
||||
v: string,
|
||||
): v is ReflectionItem["aspect"] {
|
||||
return (REFLECTION_ASPECTS as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
// ---- 节点类型守卫 ----
|
||||
export function isTextbookContentNode(
|
||||
node: { type: string },
|
||||
): node is TextbookContentNode {
|
||||
return node.type === "textbook_content";
|
||||
}
|
||||
|
||||
export function isLessonPlanNode(
|
||||
node: { type: string },
|
||||
): node is LessonPlanNode {
|
||||
return node.type !== "textbook_content";
|
||||
}
|
||||
|
||||
// ---- 题目类型守卫 ----
|
||||
const VALID_QUESTION_TYPES = [
|
||||
"single_choice",
|
||||
"multiple_choice",
|
||||
"text",
|
||||
"judgment",
|
||||
"composite",
|
||||
] as const;
|
||||
export type ValidQuestionType = (typeof VALID_QUESTION_TYPES)[number];
|
||||
|
||||
export function isValidQuestionType(v: string): v is ValidQuestionType {
|
||||
return (VALID_QUESTION_TYPES as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
// ---- BlockType 守卫 ----
|
||||
const VALID_BLOCK_TYPES: BlockType[] = [
|
||||
"objective",
|
||||
"key_point",
|
||||
"import",
|
||||
"new_teaching",
|
||||
"consolidation",
|
||||
"summary",
|
||||
"homework",
|
||||
"blackboard",
|
||||
"text_study",
|
||||
"exercise",
|
||||
"rich_text",
|
||||
"reflection",
|
||||
];
|
||||
export function isBlockType(v: string): v is BlockType {
|
||||
return (VALID_BLOCK_TYPES as readonly string[]).includes(v);
|
||||
}
|
||||
Reference in New Issue
Block a user