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:
167
src/modules/lesson-preparation/lib/document-diff.ts
Normal file
167
src/modules/lesson-preparation/lib/document-diff.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user