- 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
168 lines
4.6 KiB
TypeScript
168 lines
4.6 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|