/** * 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; 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(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 }; }