feat(lesson-preparation): add AI evaluation, analytics, attachments, calendar, comments, review, substitutes, formative, and version diff

- Add actions-ai-evaluation, actions-analytics, actions-attachments, actions-calendar, actions-comments, actions-formative, actions-questions, actions-review, actions-substitutes

- Add corresponding data-access layers for each new action module

- Add calendar-view, curriculum-map-view, version-diff-viewer components

- Add editor-slice, selection-slice, version-slice hooks for state management

- Add document-diff and scope-check lib utilities

- Add default-question-service and external-questions-bridge services
This commit is contained in:
SpecialX
2026-07-03 10:25:21 +08:00
parent a16f09d3c3
commit 20023e13fd
75 changed files with 5131 additions and 1186 deletions

View File

@@ -0,0 +1,257 @@
import type { StateCreator } from "zustand";
import { createId } from "@paralleldrive/cuid2";
import type {
AnchorEdge,
AnchorType,
AnyLessonPlanEdge,
Block,
BlockType,
FlowEdge,
LessonPlanDocument,
LessonPlanNode,
NodeAnchor,
TextbookContentNode,
TextbookContentNodeData,
} from "../types";
import { defaultDataForType } from "../lib/document-migration";
import type { EditorState } from "./use-lesson-plan-editor";
export interface EditorSlice {
planId: string;
title: string;
doc: LessonPlanDocument;
setTitle: (title: string) => void;
setPlanId: (planId: string) => void;
addNode: (type: BlockType, position?: { x: number; y: number }, title?: string) => string;
updateNode: (id: string, patch: Omit<Partial<Block>, "type">) => void;
updateNodePosition: (id: string, position: { x: number; y: number }) => void;
removeNode: (id: string) => void;
updateTextbookContent: (data: Partial<TextbookContentNodeData>) => void;
getTextbookContentNode: () => TextbookContentNode | undefined;
addAnchor: (params: {
nodeId: string;
type: AnchorType;
start: number;
end?: number;
textPreview?: string;
}) => string;
removeAnchor: (anchorId: string) => void;
updateAnchor: (anchorId: string, patch: Partial<NodeAnchor>) => void;
connect: (source: string, target: string) => void;
disconnect: (edgeId: string) => void;
setEdges: (edges: AnyLessonPlanEdge[]) => void;
}
function reindex(nodes: LessonPlanNode[]): LessonPlanNode[] {
return nodes.map((n, i) => ({ ...n, order: i }));
}
export const createEditorSlice: StateCreator<
EditorState,
[],
[],
EditorSlice
> = (set, get) => ({
planId: "",
title: "",
doc: {
version: 3,
textbookContentNodeId: "",
nodes: [],
edges: [],
anchors: [],
},
setTitle: (title) => set({ title, isDirty: true }),
setPlanId: (planId) => set({ planId }),
addNode: (type, position, title) => {
const id = createId();
const state = get();
const teachingNodes = state.doc.nodes.filter(
(n): n is LessonPlanNode => n.type !== "textbook_content",
);
const nodeCount = teachingNodes.length;
const node: LessonPlanNode = {
id,
type,
title: title ?? type,
data: defaultDataForType(type),
order: nodeCount,
position: position ?? {
x: 80 + (nodeCount % 4) * 280,
y: 80 + Math.floor(nodeCount / 4) * 200,
},
};
set((s) => ({
doc: { ...s.doc, nodes: [...s.doc.nodes, node] },
isDirty: true,
selectedNodeId: id,
}));
return id;
},
updateNode: (id, patch) =>
set((s) => ({
doc: {
...s.doc,
nodes: s.doc.nodes.map((n) =>
n.id === id
? n.type === "textbook_content"
? ({ ...n, ...patch } as TextbookContentNode)
: ({ ...n, ...patch } as LessonPlanNode)
: n,
),
},
isDirty: true,
})),
updateNodePosition: (id, position) =>
set((s) => ({
doc: {
...s.doc,
nodes: s.doc.nodes.map((n) =>
n.id === id
? n.type === "textbook_content"
? ({ ...n, position } as TextbookContentNode)
: ({ ...n, position } as LessonPlanNode)
: n,
),
},
isDirty: true,
})),
removeNode: (id) =>
set((s) => {
const remainingTeachingNodes = reindex(
s.doc.nodes.filter(
(n): n is LessonPlanNode => n.id !== id && n.type !== "textbook_content",
),
);
const textbookNode = s.doc.nodes.find(
(n): n is TextbookContentNode => n.type === "textbook_content",
);
const nodes = textbookNode
? [textbookNode, ...remainingTeachingNodes]
: remainingTeachingNodes;
return {
doc: {
...s.doc,
nodes,
edges: s.doc.edges.filter(
(e) => e.source !== id && e.target !== id,
),
anchors: s.doc.anchors.filter((a) => a.nodeId !== id),
},
isDirty: true,
selectedNodeId: s.selectedNodeId === id ? null : s.selectedNodeId,
};
}),
updateTextbookContent: (data) =>
set((s) => ({
doc: {
...s.doc,
nodes: s.doc.nodes.map((n) =>
n.type === "textbook_content" && n.id === s.doc.textbookContentNodeId
? { ...n, data: { ...n.data, ...data } }
: n,
),
},
isDirty: true,
})),
getTextbookContentNode: () => {
const state = get();
return state.doc.nodes.find(
(n): n is TextbookContentNode => n.type === "textbook_content",
);
},
addAnchor: ({ nodeId, type, start, end, textPreview }) => {
const anchorId = createId();
const state = get();
const textbookNodeId = state.doc.textbookContentNodeId;
const anchor: NodeAnchor = {
id: anchorId,
nodeId,
type,
start,
...(end !== undefined ? { end } : {}),
...(textPreview ? { textPreview } : {}),
};
const edge: AnchorEdge = {
id: `ae_${nodeId}_${textbookNodeId}_${anchorId.slice(0, 6)}`,
source: nodeId,
target: textbookNodeId,
type: "anchor",
anchorId,
};
set((s) => ({
doc: {
...s.doc,
anchors: [...s.doc.anchors, anchor],
edges: [...s.doc.edges, edge],
},
isDirty: true,
}));
return anchorId;
},
removeAnchor: (anchorId) =>
set((s) => ({
doc: {
...s.doc,
anchors: s.doc.anchors.filter((a) => a.id !== anchorId),
edges: s.doc.edges.filter(
(e) => !(e.type === "anchor" && e.anchorId === anchorId),
),
},
isDirty: true,
})),
updateAnchor: (anchorId, patch) =>
set((s) => ({
doc: {
...s.doc,
anchors: s.doc.anchors.map((a) =>
a.id === anchorId ? { ...a, ...patch } : a,
),
},
isDirty: true,
})),
connect: (source, target) =>
set((s) => {
if (
s.doc.edges.some((e) => e.source === source && e.target === target)
)
return s;
const edge: FlowEdge = {
id: `e_${source}_${target}_${createId().slice(0, 6)}`,
source,
target,
type: "flow",
};
return {
doc: { ...s.doc, edges: [...s.doc.edges, edge] },
isDirty: true,
};
}),
disconnect: (edgeId) =>
set((s) => ({
doc: {
...s.doc,
edges: s.doc.edges.filter((e) => e.id !== edgeId),
},
isDirty: true,
})),
setEdges: (edges) =>
set((s) => ({ doc: { ...s.doc, edges }, isDirty: true })),
});