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:
SpecialX
2026-06-24 12:02:42 +08:00
parent a48e7d0e27
commit 6bc113eaff
39 changed files with 2129 additions and 571 deletions

View File

@@ -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) => ({

View 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 };
}

View File

@@ -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 存入 datafromRfEdges 从 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,
};
}

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