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,167 @@
/**
* M11 版本 diff 预览 - 纯函数模块
*
* 基于字符串差异对比算法,输出可用于 React 渲染的 diff 段落。
* 实现思路:
* - 将 LessonPlanDocument 序列化为可读文本(节点列表 + 标题)
* - 使用简化的 LCS 算法对比两段文本
* - 输出 added/removed/unchanged 三种段落
*
* 此模块仅做纯计算UI 渲染由 version-diff-viewer.tsx 负责。
*/
import type { LessonPlanDocument } from "../types";
/** diff 段落类型 */
export type DiffSegmentType = "added" | "removed" | "unchanged";
/** diff 单个段落 */
export interface DiffSegment {
type: DiffSegmentType;
content: string;
lineNumber?: number;
}
/**
* 将 LessonPlanDocument 序列化为可读文本(用于 diff 比对)
* 每行一个节点,包含节点类型、标题、关键字段摘要
*/
export function serializeDocumentToText(doc: LessonPlanDocument): string {
const lines: string[] = [];
lines.push(`Version: ${doc.version}`);
lines.push(`TextbookNodeId: ${doc.textbookContentNodeId}`);
lines.push(`Nodes (${doc.nodes.length}):`);
for (const node of doc.nodes) {
const summary = summarizeNode(node);
lines.push(` [${node.type}] ${node.title ?? "(no title)"} - ${summary}`);
}
lines.push(`Edges (${doc.edges.length}):`);
for (const edge of doc.edges) {
lines.push(` ${edge.source}${edge.target} (${"type" in edge ? edge.type : "unknown"})`);
}
lines.push(`Anchors (${doc.anchors.length}):`);
for (const anchor of doc.anchors) {
lines.push(
` ${anchor.id}: node=${anchor.nodeId} type=${anchor.type} start=${anchor.start}${anchor.end !== undefined ? ` end=${anchor.end}` : ""}`,
);
}
return lines.join("\n");
}
/**
* 节点摘要(提取关键字段以便 diff 可读)
*/
function summarizeNode(node: { data?: unknown; type: string }): string {
if (!node.data || typeof node.data !== "object") return "";
const data = node.data as Record<string, unknown>;
const fields: string[] = [];
for (const key of Object.keys(data).slice(0, 5)) {
const value = data[key];
if (typeof value === "string") {
fields.push(`${key}="${value.slice(0, 60)}"`);
} else if (Array.isArray(value)) {
fields.push(`${key}=[${value.length} items]`);
} else if (typeof value === "number" || typeof value === "boolean") {
fields.push(`${key}=${value}`);
}
}
return fields.join(", ");
}
/**
* 简化 LCS diff 算法
* 输入两段文本,输出 diff 段落数组
*
* 时间复杂度 O(n*m),对于教案文档(通常 < 500 行)可接受。
* 对于更长文本可换用 diff-match-patch 库。
*/
export function computeTextDiff(
oldText: string,
newText: string,
): DiffSegment[] {
const oldLines = oldText.split("\n");
const newLines = newText.split("\n");
// 构建 LCS 矩阵
const m = oldLines.length;
const n = newLines.length;
const lcs: number[][] = Array.from({ length: m + 1 }, () =>
new Array<number>(n + 1).fill(0),
);
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (oldLines[i - 1] === newLines[j - 1]) {
lcs[i][j] = lcs[i - 1][j - 1] + 1;
} else {
lcs[i][j] = Math.max(lcs[i - 1][j], lcs[i][j - 1]);
}
}
}
// 回溯生成 diff 段落
const segments: DiffSegment[] = [];
let i = m;
let j = n;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
segments.unshift({
type: "unchanged",
content: oldLines[i - 1]!,
lineNumber: i,
});
i--;
j--;
} else if (j > 0 && (i === 0 || lcs[i][j - 1] >= lcs[i - 1][j])) {
segments.unshift({
type: "added",
content: newLines[j - 1]!,
lineNumber: j,
});
j--;
} else if (i > 0) {
segments.unshift({
type: "removed",
content: oldLines[i - 1]!,
lineNumber: i,
});
i--;
}
}
return segments;
}
/**
* 计算两个课案文档的 diff
*/
export function computeDocumentDiff(
oldDoc: LessonPlanDocument,
newDoc: LessonPlanDocument,
): DiffSegment[] {
return computeTextDiff(
serializeDocumentToText(oldDoc),
serializeDocumentToText(newDoc),
);
}
/**
* 统计 diff 摘要
*/
export function summarizeDiff(
segments: DiffSegment[],
): { added: number; removed: number; unchanged: number; total: number } {
let added = 0;
let removed = 0;
let unchanged = 0;
for (const seg of segments) {
if (seg.type === "added") added++;
else if (seg.type === "removed") removed++;
else unchanged++;
}
return { added, removed, unchanged, total: segments.length };
}