Files
NextEdu/src/modules/lesson-preparation/lib/document-diff.ts
SpecialX 20023e13fd 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
2026-07-03 10:25:21 +08:00

168 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
}