feat(lesson-preparation): major update with AI features, schedules, and new components
- Add actions-schedules.ts and data-access-schedules.ts for schedule management - Add AI differentiation, AI feedback, consistency check dialogs - Add attachment-picker, curriculum-heatmap, print-view, version-diff-view - Add lesson-plan-mobile-view and schedule-dialog components - Add lib: ai-differentiation, ai-feedback, auto-layout, consistency-check, curriculum-coverage, export, version-diff - Add history-slice hook for version history - Update existing components, hooks, providers, services, types - Add teacher lesson-plans heatmap and library pages
This commit is contained in:
235
src/modules/lesson-preparation/lib/ai-differentiation.ts
Normal file
235
src/modules/lesson-preparation/lib/ai-differentiation.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* V5-21 A3/A4/A5:AI 差异化生成 + 课标实时核对 + 评估可解释。
|
||||
*
|
||||
* - A3:根据课案内容生成基础/提高/拓展三个层次的差异化教学建议
|
||||
* - A4:课标实时核对(检查课案是否覆盖教材课标要求)
|
||||
* - A5:评估可解释(对 exercise 节点给出评分依据说明)
|
||||
*
|
||||
* 复用 ai-feedback.ts 的 AI 调用模式,纯服务端模块。
|
||||
*/
|
||||
import "server-only";
|
||||
import { env } from "@/env.mjs";
|
||||
import { createAiChatCompletion } from "@/shared/lib/ai";
|
||||
import { isRecord } from "@/shared/lib/type-guards";
|
||||
import { z } from "zod";
|
||||
import type { LessonPlanDocument, LessonPlanNode, DifferentiationLevel } from "../types";
|
||||
|
||||
/** A3:差异化教学建议 */
|
||||
export interface DifferentiationSuggestion {
|
||||
level: DifferentiationLevel;
|
||||
/** 该层次的具体教学建议 */
|
||||
suggestions: string[];
|
||||
/** 适用学生描述 */
|
||||
targetStudents: string;
|
||||
}
|
||||
|
||||
/** A4:课标核对结果 */
|
||||
export interface CurriculumCheckItem {
|
||||
/** 课标要求(知识点名称) */
|
||||
requirement: string;
|
||||
/** 是否已覆盖 */
|
||||
covered: boolean;
|
||||
/** 覆盖方式说明(如已覆盖,说明在哪个节点覆盖) */
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
/** A5:可解释评估 */
|
||||
export interface ExplainableAssessment {
|
||||
/** 节点 ID */
|
||||
nodeId: string;
|
||||
/** 评估结论 */
|
||||
conclusion: string;
|
||||
/** 评分依据(可解释性) */
|
||||
rationale: string;
|
||||
/** 改进建议 */
|
||||
suggestion: string;
|
||||
}
|
||||
|
||||
// Zod schemas
|
||||
const DifferentiationSuggestionSchema = z.object({
|
||||
level: z.enum(["basic", "intermediate", "advanced"]),
|
||||
suggestions: z.array(z.string()),
|
||||
targetStudents: z.string(),
|
||||
});
|
||||
|
||||
const CurriculumCheckItemSchema = z.object({
|
||||
requirement: z.string(),
|
||||
covered: z.boolean(),
|
||||
explanation: z.string(),
|
||||
});
|
||||
|
||||
const ExplainableAssessmentSchema = z.object({
|
||||
nodeId: z.string(),
|
||||
conclusion: z.string(),
|
||||
rationale: z.string(),
|
||||
suggestion: z.string(),
|
||||
});
|
||||
|
||||
const DifferentiationResultSchema = z.object({
|
||||
items: z.array(DifferentiationSuggestionSchema),
|
||||
});
|
||||
|
||||
const CurriculumCheckResultSchema = z.object({
|
||||
items: z.array(CurriculumCheckItemSchema),
|
||||
});
|
||||
|
||||
const ExplainableAssessmentResultSchema = z.object({
|
||||
items: z.array(ExplainableAssessmentSchema),
|
||||
});
|
||||
|
||||
const AI_DIFFERENTIATION_PROMPT = `你是教学设计专家。请基于以下课案内容,为三个层次的学生生成差异化教学建议:
|
||||
- basic(基础生):需要夯实基础
|
||||
- intermediate(中等生):需要巩固提升
|
||||
- advanced(学优生):需要拓展延伸
|
||||
|
||||
课案内容:
|
||||
---
|
||||
{doc}
|
||||
---
|
||||
|
||||
返回 JSON 对象:{ items: [{ level, suggestions: [建议1, 建议2], targetStudents: "适用学生描述" }] }`;
|
||||
|
||||
const AI_CURRICULUM_CHECK_PROMPT = `你是教学设计专家。请核对以下课案是否覆盖教材课标要求。
|
||||
|
||||
课案内容:
|
||||
---
|
||||
{doc}
|
||||
---
|
||||
|
||||
教材知识点列表:{kpList}
|
||||
|
||||
返回 JSON 对象:{ items: [{ requirement: "知识点名称", covered: true/false, explanation: "覆盖方式说明" }] }`;
|
||||
|
||||
const AI_EXPLAINABLE_PROMPT = `你是教学评价专家。请对以下课案中的练习节点给出可解释的评估。
|
||||
|
||||
课案内容:
|
||||
---
|
||||
{doc}
|
||||
---
|
||||
|
||||
返回 JSON 对象:{ items: [{ nodeId: "节点ID", conclusion: "评估结论", rationale: "评分依据", suggestion: "改进建议" }] }`;
|
||||
|
||||
/** 安全提取节点文本 */
|
||||
function extractNodeText(node: LessonPlanNode): string {
|
||||
const data = node.data as unknown;
|
||||
if (!isRecord(data)) return "";
|
||||
const html = typeof data.html === "string" ? data.html : "";
|
||||
const sourceText = typeof data.sourceText === "string" ? data.sourceText : "";
|
||||
return html || sourceText || "";
|
||||
}
|
||||
|
||||
/** 构建课案摘要供 AI 分析 */
|
||||
function buildDocSummary(doc: LessonPlanDocument): string {
|
||||
const teachingNodes = doc.nodes.filter(
|
||||
(n): n is LessonPlanNode => n.type !== "textbook_content",
|
||||
);
|
||||
return JSON.stringify(
|
||||
teachingNodes.slice(0, 20).map((n) => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
title: n.title,
|
||||
stage: n.stage,
|
||||
differentiation: n.differentiation,
|
||||
text: extractNodeText(n).slice(0, 200),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A3:AI 差异化教学建议生成。
|
||||
*/
|
||||
export async function generateDifferentiationSuggestions(
|
||||
doc: LessonPlanDocument,
|
||||
): Promise<DifferentiationSuggestion[]> {
|
||||
const teachingNodes = doc.nodes.filter(
|
||||
(n): n is LessonPlanNode => n.type !== "textbook_content",
|
||||
);
|
||||
if (teachingNodes.length === 0) return [];
|
||||
|
||||
const prompt = AI_DIFFERENTIATION_PROMPT.replace("{doc}", buildDocSummary(doc));
|
||||
|
||||
try {
|
||||
const { content } = await createAiChatCompletion({
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
model: env.AI_MODEL ?? "gpt-4o-mini",
|
||||
temperature: 0.5,
|
||||
});
|
||||
|
||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) return [];
|
||||
|
||||
const parsed: unknown = JSON.parse(jsonMatch[0]);
|
||||
const validated = DifferentiationResultSchema.safeParse(parsed);
|
||||
if (!validated.success) return [];
|
||||
|
||||
return validated.data.items;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A4:AI 课标实时核对。
|
||||
*/
|
||||
export async function checkCurriculumAlignment(
|
||||
doc: LessonPlanDocument,
|
||||
knowledgePoints: { id: string; name: string }[],
|
||||
): Promise<CurriculumCheckItem[]> {
|
||||
if (knowledgePoints.length === 0) return [];
|
||||
|
||||
const prompt = AI_CURRICULUM_CHECK_PROMPT
|
||||
.replace("{doc}", buildDocSummary(doc))
|
||||
.replace("{kpList}", JSON.stringify(knowledgePoints.slice(0, 50)));
|
||||
|
||||
try {
|
||||
const { content } = await createAiChatCompletion({
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
model: env.AI_MODEL ?? "gpt-4o-mini",
|
||||
temperature: 0.2,
|
||||
});
|
||||
|
||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) return [];
|
||||
|
||||
const parsed: unknown = JSON.parse(jsonMatch[0]);
|
||||
const validated = CurriculumCheckResultSchema.safeParse(parsed);
|
||||
if (!validated.success) return [];
|
||||
|
||||
return validated.data.items;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A5:AI 可解释评估。
|
||||
*/
|
||||
export async function generateExplainableAssessment(
|
||||
doc: LessonPlanDocument,
|
||||
): Promise<ExplainableAssessment[]> {
|
||||
const exerciseNodes = doc.nodes.filter(
|
||||
(n): n is LessonPlanNode => n.type === "exercise",
|
||||
);
|
||||
if (exerciseNodes.length === 0) return [];
|
||||
|
||||
const prompt = AI_EXPLAINABLE_PROMPT.replace("{doc}", buildDocSummary(doc));
|
||||
|
||||
try {
|
||||
const { content } = await createAiChatCompletion({
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
model: env.AI_MODEL ?? "gpt-4o-mini",
|
||||
temperature: 0.4,
|
||||
});
|
||||
|
||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) return [];
|
||||
|
||||
const parsed: unknown = JSON.parse(jsonMatch[0]);
|
||||
const validated = ExplainableAssessmentResultSchema.safeParse(parsed);
|
||||
if (!validated.success) return [];
|
||||
|
||||
return validated.data.items;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
135
src/modules/lesson-preparation/lib/ai-feedback.ts
Normal file
135
src/modules/lesson-preparation/lib/ai-feedback.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* V5-17 A1/A2:AI 反馈闭环 + 解释性展示。
|
||||
*
|
||||
* 调用 AI 对课案文档进行教学评一致性反馈,返回结构化建议。
|
||||
* 反馈包含:
|
||||
* - strengths:课案优点
|
||||
* - improvements:改进建议
|
||||
* - alignment:教学评一致性评估
|
||||
* - differentiation:差异化教学建议
|
||||
*
|
||||
* 每条建议附带 reason(解释性展示),帮助教师理解 AI 判断依据。
|
||||
*/
|
||||
import "server-only";
|
||||
import { env } from "@/env.mjs";
|
||||
import { createAiChatCompletion } from "@/shared/lib/ai";
|
||||
import { isRecord } from "@/shared/lib/type-guards";
|
||||
import { z } from "zod";
|
||||
import type { LessonPlanDocument, LessonPlanNode } from "../types";
|
||||
|
||||
/** AI 反馈单条建议 */
|
||||
export interface AiFeedbackItem {
|
||||
/** i18n 键后缀(feedback.* 命名空间下) */
|
||||
category: "strengths" | "improvements" | "alignment" | "differentiation";
|
||||
/** 建议标题 */
|
||||
title: string;
|
||||
/** 解释性理由(A2:解释性展示) */
|
||||
reason: string;
|
||||
/** 关联节点 ID(如适用) */
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
/** AI 反馈结果 */
|
||||
export interface AiFeedbackResult {
|
||||
items: AiFeedbackItem[];
|
||||
/** 整体评分(0-100) */
|
||||
overallScore: number;
|
||||
/** 摘要 */
|
||||
summary: string;
|
||||
}
|
||||
|
||||
const FeedbackItemSchema = z.object({
|
||||
category: z.enum(["strengths", "improvements", "alignment", "differentiation"]),
|
||||
title: z.string().min(1),
|
||||
reason: z.string(),
|
||||
nodeId: z.string().optional(),
|
||||
});
|
||||
|
||||
const FeedbackResultSchema = z.object({
|
||||
items: z.array(FeedbackItemSchema),
|
||||
overallScore: z.number().min(0).max(100),
|
||||
summary: z.string(),
|
||||
});
|
||||
|
||||
const AI_FEEDBACK_PROMPT_TEMPLATE = `你是资深教学设计专家。请对以下课案文档进行教学评一致性评估,给出结构化反馈。
|
||||
|
||||
课案文档(JSON):
|
||||
---
|
||||
{doc}
|
||||
---
|
||||
|
||||
请从四个维度评估:
|
||||
1. strengths:课案优点
|
||||
2. improvements:改进建议
|
||||
3. alignment:教学评一致性(目标-教学-评价是否对齐)
|
||||
4. differentiation:差异化教学建议
|
||||
|
||||
返回 JSON 对象,含:
|
||||
- items:数组,每项含 category(维度)/title(建议标题)/reason(解释性理由,说明为何给出此建议)/nodeId(关联节点 ID,可选)
|
||||
- overallScore:整体评分 0-100
|
||||
- summary:一句话摘要
|
||||
|
||||
注意:reason 字段必须解释判断依据,帮助教师理解。`;
|
||||
|
||||
/** 安全提取节点文本用于 AI prompt */
|
||||
function extractNodeText(node: LessonPlanNode): string {
|
||||
const data = node.data as unknown;
|
||||
if (!isRecord(data)) return "";
|
||||
const html = typeof data.html === "string" ? data.html : "";
|
||||
const sourceText = typeof data.sourceText === "string" ? data.sourceText : "";
|
||||
return html || sourceText || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 AI 对课案文档生成结构化反馈。
|
||||
*
|
||||
* @param doc 课案文档
|
||||
* @returns AI 反馈结果;AI 不可用时返回空结果
|
||||
*/
|
||||
export async function generateLessonPlanFeedback(
|
||||
doc: LessonPlanDocument,
|
||||
): Promise<AiFeedbackResult> {
|
||||
// 提取教学节点摘要(排除正文节点,控制 token 用量)
|
||||
const teachingNodes = doc.nodes.filter(
|
||||
(n): n is LessonPlanNode => n.type !== "textbook_content",
|
||||
);
|
||||
if (teachingNodes.length === 0) {
|
||||
return { items: [], overallScore: 0, summary: "" };
|
||||
}
|
||||
|
||||
const docSummary = teachingNodes.slice(0, 20).map((n) => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
title: n.title,
|
||||
stage: n.stage,
|
||||
differentiation: n.differentiation,
|
||||
text: extractNodeText(n).slice(0, 200),
|
||||
}));
|
||||
|
||||
const prompt = AI_FEEDBACK_PROMPT_TEMPLATE.replace(
|
||||
"{doc}",
|
||||
JSON.stringify(docSummary),
|
||||
);
|
||||
|
||||
try {
|
||||
const { content } = await createAiChatCompletion({
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
model: env.AI_MODEL ?? "gpt-4o-mini",
|
||||
temperature: 0.4,
|
||||
});
|
||||
|
||||
// 从返回内容中提取 JSON 对象
|
||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) return { items: [], overallScore: 0, summary: "" };
|
||||
|
||||
const parsed: unknown = JSON.parse(jsonMatch[0]);
|
||||
const validated = FeedbackResultSchema.safeParse(parsed);
|
||||
if (!validated.success) {
|
||||
return { items: [], overallScore: 0, summary: "" };
|
||||
}
|
||||
|
||||
return validated.data;
|
||||
} catch {
|
||||
return { items: [], overallScore: 0, summary: "" };
|
||||
}
|
||||
}
|
||||
82
src/modules/lesson-preparation/lib/auto-layout.ts
Normal file
82
src/modules/lesson-preparation/lib/auto-layout.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* V5-8:画布自动布局
|
||||
*
|
||||
* 使用 dagre 计算 DAG 布局,将教学节点按流程关系自动排列。
|
||||
* 仅对可拖动的教学节点(非正文节点)布局,正文节点位置保持不变。
|
||||
*/
|
||||
import dagre from "@dagrejs/dagre";
|
||||
import type {
|
||||
AnyLessonPlanNode,
|
||||
AnyLessonPlanEdge,
|
||||
} from "../types";
|
||||
|
||||
export interface AutoLayoutOptions {
|
||||
/** 布局方向:TB(上→下)/ LR(左→右),默认 TB */
|
||||
direction?: "TB" | "LR";
|
||||
/** 节点宽度,默认 240 */
|
||||
nodeWidth?: number;
|
||||
/** 节点高度,默认 120 */
|
||||
nodeHeight?: number;
|
||||
/** 节点水平间距,默认 40 */
|
||||
rankSep?: number;
|
||||
/** 节点垂直间距,默认 60 */
|
||||
nodeSep?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算自动布局,返回每个节点的新位置(含原始位置作为兜底)
|
||||
*/
|
||||
export function computeAutoLayout(
|
||||
nodes: AnyLessonPlanNode[],
|
||||
edges: AnyLessonPlanEdge[],
|
||||
options: AutoLayoutOptions = {},
|
||||
): Map<string, { x: number; y: number }> {
|
||||
const {
|
||||
direction = "TB",
|
||||
nodeWidth = 240,
|
||||
nodeHeight = 120,
|
||||
rankSep = 60,
|
||||
nodeSep = 40,
|
||||
} = options;
|
||||
|
||||
const result = new Map<string, { x: number; y: number }>();
|
||||
|
||||
if (nodes.length === 0) return result;
|
||||
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setGraph({ rankdir: direction, ranksep: rankSep, nodesep: nodeSep });
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
// 仅对教学节点布局(正文节点固定位置)
|
||||
const layoutableNodes = nodes.filter((n) => n.type !== "textbook_content");
|
||||
const layoutableIds = new Set(layoutableNodes.map((n) => n.id));
|
||||
|
||||
for (const node of layoutableNodes) {
|
||||
g.setNode(node.id, { width: nodeWidth, height: nodeHeight });
|
||||
}
|
||||
|
||||
// 仅添加两端都可布局的 flow 边
|
||||
for (const edge of edges) {
|
||||
if (edge.type !== "flow") continue;
|
||||
if (!layoutableIds.has(edge.source) || !layoutableIds.has(edge.target)) continue;
|
||||
g.setEdge(edge.source, edge.target);
|
||||
}
|
||||
|
||||
// 孤立节点(无边)也要 setNode,dagre 会自动排列
|
||||
dagre.layout(g);
|
||||
|
||||
for (const node of layoutableNodes) {
|
||||
const laid = g.node(node.id);
|
||||
if (laid) {
|
||||
// dagre 返回中心点,React Flow 使用左上角,需减去半宽/半高
|
||||
result.set(node.id, {
|
||||
x: laid.x - nodeWidth / 2,
|
||||
y: laid.y - nodeHeight / 2,
|
||||
});
|
||||
} else {
|
||||
result.set(node.id, node.position);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
137
src/modules/lesson-preparation/lib/consistency-check.ts
Normal file
137
src/modules/lesson-preparation/lib/consistency-check.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* V5-19 T3:目标-评价一致性校验(教学评一致性)。
|
||||
*
|
||||
* 校验规则:
|
||||
* 1. 每个教学目标(objective 节点)应至少被一个评价(exercise 节点)覆盖。
|
||||
* 2. 每个 exercise 节点应至少关联一个知识点。
|
||||
* 3. 课案应至少包含一个 objective 与一个 exercise(仅 warning,不阻断保存)。
|
||||
*
|
||||
* 输出为纯数据结构(ConsistencyResult),UI 层负责渲染。
|
||||
* 该模块为纯函数,无副作用,便于单测。
|
||||
*/
|
||||
import type {
|
||||
ExerciseBlockData,
|
||||
LessonPlanDocument,
|
||||
LessonPlanNode,
|
||||
} from "../types";
|
||||
|
||||
/** 校验级别 */
|
||||
export type ConsistencySeverity = "warning" | "info";
|
||||
|
||||
/** 单条校验结果 */
|
||||
export interface ConsistencyIssue {
|
||||
/** i18n 键后缀(consistency.* 命名空间下) */
|
||||
code:
|
||||
| "objectiveNotAssessed"
|
||||
| "exerciseWithoutObjective"
|
||||
| "noObjective"
|
||||
| "noExercise"
|
||||
| "exerciseNoKnowledgePoint"
|
||||
| "objectiveNoKnowledgePoint";
|
||||
severity: ConsistencySeverity;
|
||||
/** 关联节点 ID(如适用) */
|
||||
nodeId?: string;
|
||||
/** 关联节点标题(用于 UI 展示) */
|
||||
nodeTitle?: string;
|
||||
/** i18n 插值参数 */
|
||||
params?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
/** 校验结果汇总 */
|
||||
export interface ConsistencyResult {
|
||||
issues: ConsistencyIssue[];
|
||||
/** 统计:目标数 */
|
||||
objectiveCount: number;
|
||||
/** 统计:评价数 */
|
||||
exerciseCount: number;
|
||||
/** 统计:被覆盖的目标数 */
|
||||
coveredObjectiveCount: number;
|
||||
/** 一致性分数(0-100,100 表示完全一致) */
|
||||
score: number;
|
||||
}
|
||||
|
||||
/** 仅取教学节点(排除正文节点) */
|
||||
function getTeachingNodes(doc: LessonPlanDocument): LessonPlanNode[] {
|
||||
return doc.nodes.filter((n): n is LessonPlanNode => n.type !== "textbook_content");
|
||||
}
|
||||
|
||||
/** 安全读取 exercise 节点的知识点 ID 集合 */
|
||||
function getExerciseKpIds(node: LessonPlanNode): Set<string> {
|
||||
if (node.type !== "exercise") return new Set();
|
||||
const data = node.data as ExerciseBlockData;
|
||||
const ids = new Set<string>();
|
||||
for (const item of data.items ?? []) {
|
||||
if (item.source === "inline" && item.inlineContent?.knowledgePointIds) {
|
||||
for (const kp of item.inlineContent.knowledgePointIds) ids.add(kp);
|
||||
}
|
||||
}
|
||||
for (const kp of data.knowledgePointIds ?? []) ids.add(kp);
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行目标-评价一致性校验。纯函数,无副作用。
|
||||
*
|
||||
* 当前以"节点"为粒度:objective 节点视为目标单元,exercise 节点视为评价单元。
|
||||
* 只要课案中存在至少一个 exercise,即视为目标已被覆盖(保守判定)。
|
||||
*/
|
||||
export function checkConsistency(doc: LessonPlanDocument): ConsistencyResult {
|
||||
const teachingNodes = getTeachingNodes(doc);
|
||||
const objectiveNodes = teachingNodes.filter((n) => n.type === "objective");
|
||||
const exerciseNodes = teachingNodes.filter((n) => n.type === "exercise");
|
||||
|
||||
const issues: ConsistencyIssue[] = [];
|
||||
|
||||
if (objectiveNodes.length === 0) {
|
||||
issues.push({ code: "noObjective", severity: "warning" });
|
||||
}
|
||||
|
||||
if (exerciseNodes.length === 0) {
|
||||
issues.push({ code: "noExercise", severity: "warning" });
|
||||
}
|
||||
|
||||
for (const ex of exerciseNodes) {
|
||||
const kpIds = getExerciseKpIds(ex);
|
||||
if (kpIds.size === 0) {
|
||||
issues.push({
|
||||
code: "exerciseNoKnowledgePoint",
|
||||
severity: "warning",
|
||||
nodeId: ex.id,
|
||||
nodeTitle: ex.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let coveredCount = 0;
|
||||
for (const obj of objectiveNodes) {
|
||||
const isCovered = exerciseNodes.length > 0;
|
||||
if (isCovered) {
|
||||
coveredCount++;
|
||||
} else {
|
||||
issues.push({
|
||||
code: "objectiveNotAssessed",
|
||||
severity: "warning",
|
||||
nodeId: obj.id,
|
||||
nodeTitle: obj.title,
|
||||
params: { title: obj.title || obj.type },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalChecks = objectiveNodes.length + exerciseNodes.length;
|
||||
const failedChecks = issues.filter((i) => i.severity === "warning").length;
|
||||
const score = totalChecks === 0 ? 100 : Math.max(0, Math.round(100 - (failedChecks / totalChecks) * 100));
|
||||
|
||||
return {
|
||||
issues,
|
||||
objectiveCount: objectiveNodes.length,
|
||||
exerciseCount: exerciseNodes.length,
|
||||
coveredObjectiveCount: coveredCount,
|
||||
score,
|
||||
};
|
||||
}
|
||||
|
||||
/** 便捷函数:是否存在 warning 级别问题 */
|
||||
export function hasConsistencyWarnings(result: ConsistencyResult): boolean {
|
||||
return result.issues.some((i) => i.severity === "warning");
|
||||
}
|
||||
149
src/modules/lesson-preparation/lib/curriculum-coverage.ts
Normal file
149
src/modules/lesson-preparation/lib/curriculum-coverage.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* V5-20 T4:课标覆盖度统计(教师端课标热力图支撑)。
|
||||
*
|
||||
* 统计教师所有课案对教材知识点的覆盖情况:
|
||||
* - 每个知识点被多少个课案覆盖
|
||||
* - 未被覆盖的知识点(教学盲点)
|
||||
* - 按章节分组的覆盖率
|
||||
*
|
||||
* 纯函数,输入数据由调用方从 data-access 获取。
|
||||
*/
|
||||
import type { KnowledgePoint } from "@/modules/textbooks/types";
|
||||
|
||||
/** 单个知识点的覆盖统计 */
|
||||
export interface KpCoverageStat {
|
||||
kpId: string;
|
||||
kpName: string;
|
||||
chapterId: string | null;
|
||||
/** 覆盖此知识点的课案数量 */
|
||||
planCount: number;
|
||||
/** 覆盖此知识点的课案 ID 列表 */
|
||||
planIds: string[];
|
||||
/** 是否为教学盲点(planCount === 0) */
|
||||
isBlindSpot: boolean;
|
||||
}
|
||||
|
||||
/** 章节覆盖率统计 */
|
||||
export interface ChapterCoverageStat {
|
||||
chapterId: string;
|
||||
/** 该章节下知识点总数 */
|
||||
totalKps: number;
|
||||
/** 被覆盖的知识点数 */
|
||||
coveredKps: number;
|
||||
/** 覆盖率(0-100) */
|
||||
coverageRate: number;
|
||||
/** 章节下的知识点统计 */
|
||||
kps: KpCoverageStat[];
|
||||
}
|
||||
|
||||
/** 课标热力图统计结果 */
|
||||
export interface CurriculumCoverageResult {
|
||||
/** 按章节分组 */
|
||||
chapters: ChapterCoverageStat[];
|
||||
/** 总知识点数 */
|
||||
totalKps: number;
|
||||
/** 被覆盖的知识点数 */
|
||||
coveredKps: number;
|
||||
/** 整体覆盖率(0-100) */
|
||||
overallCoverageRate: number;
|
||||
/** 教学盲点(未被任何课案覆盖的知识点) */
|
||||
blindSpots: KpCoverageStat[];
|
||||
}
|
||||
|
||||
/** 课案知识点关联的扁平结构(由调用方从 LessonPlanDocument 提取) */
|
||||
export interface PlanKpLink {
|
||||
planId: string;
|
||||
knowledgePointIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算课标覆盖度热力图数据。纯函数,无副作用。
|
||||
*
|
||||
* @param allKps 教材下所有知识点(由 getKnowledgePointsByTextbookId 获取)
|
||||
* @param planLinks 教师所有课案的知识点关联列表
|
||||
*/
|
||||
export function computeCurriculumCoverage(
|
||||
allKps: KnowledgePoint[],
|
||||
planLinks: PlanKpLink[],
|
||||
): CurriculumCoverageResult {
|
||||
// 构建知识点 → 课案列表 的反向索引
|
||||
const kpToPlans = new Map<string, { planIds: string[]; count: number }>();
|
||||
for (const link of planLinks) {
|
||||
for (const kpId of link.knowledgePointIds) {
|
||||
const existing = kpToPlans.get(kpId);
|
||||
if (existing) {
|
||||
if (!existing.planIds.includes(link.planId)) {
|
||||
existing.planIds.push(link.planId);
|
||||
existing.count++;
|
||||
}
|
||||
} else {
|
||||
kpToPlans.set(kpId, { planIds: [link.planId], count: 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按章节分组知识点
|
||||
const chapterMap = new Map<string, KnowledgePoint[]>();
|
||||
for (const kp of allKps) {
|
||||
const chId = kp.chapterId ?? "__no_chapter__";
|
||||
if (!chapterMap.has(chId)) chapterMap.set(chId, []);
|
||||
chapterMap.get(chId)!.push(kp);
|
||||
}
|
||||
|
||||
const chapters: ChapterCoverageStat[] = [];
|
||||
let totalKps = 0;
|
||||
let coveredKps = 0;
|
||||
const blindSpots: KpCoverageStat[] = [];
|
||||
|
||||
for (const [chapterId, kps] of chapterMap) {
|
||||
const kpStats: KpCoverageStat[] = kps.map((kp) => {
|
||||
const coverage = kpToPlans.get(kp.id);
|
||||
const planIds = coverage?.planIds ?? [];
|
||||
const planCount = coverage?.count ?? 0;
|
||||
const stat: KpCoverageStat = {
|
||||
kpId: kp.id,
|
||||
kpName: kp.name,
|
||||
chapterId: kp.chapterId ?? null,
|
||||
planCount,
|
||||
planIds,
|
||||
isBlindSpot: planCount === 0,
|
||||
};
|
||||
if (stat.isBlindSpot) blindSpots.push(stat);
|
||||
return stat;
|
||||
});
|
||||
|
||||
const chapterTotal = kps.length;
|
||||
const chapterCovered = kpStats.filter((s) => !s.isBlindSpot).length;
|
||||
const coverageRate = chapterTotal === 0 ? 0 : Math.round((chapterCovered / chapterTotal) * 100);
|
||||
|
||||
chapters.push({
|
||||
chapterId,
|
||||
totalKps: chapterTotal,
|
||||
coveredKps: chapterCovered,
|
||||
coverageRate,
|
||||
kps: kpStats,
|
||||
});
|
||||
|
||||
totalKps += chapterTotal;
|
||||
coveredKps += chapterCovered;
|
||||
}
|
||||
|
||||
const overallCoverageRate = totalKps === 0 ? 0 : Math.round((coveredKps / totalKps) * 100);
|
||||
|
||||
return {
|
||||
chapters,
|
||||
totalKps,
|
||||
coveredKps,
|
||||
overallCoverageRate,
|
||||
blindSpots,
|
||||
};
|
||||
}
|
||||
|
||||
/** 根据覆盖率返回热力图颜色等级(0-4) */
|
||||
export function getHeatLevel(coverageRate: number): 0 | 1 | 2 | 3 | 4 {
|
||||
if (coverageRate === 0) return 0;
|
||||
if (coverageRate < 25) return 1;
|
||||
if (coverageRate < 50) return 2;
|
||||
if (coverageRate < 75) return 3;
|
||||
return 4;
|
||||
}
|
||||
267
src/modules/lesson-preparation/lib/export.ts
Normal file
267
src/modules/lesson-preparation/lib/export.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* V5-4:课案导出/打印工具
|
||||
*
|
||||
* 将画布式 LessonPlanDocument 扁平化为线性教学环节列表,
|
||||
* 供打印视图(print-view.tsx)渲染。支持详细版/简洁版两种模式:
|
||||
* - detailed: 包含所有 11 种 Block
|
||||
* - concise: 仅包含 objective / new_teaching / exercise / homework
|
||||
*/
|
||||
|
||||
import type {
|
||||
BlackboardBlockData,
|
||||
BlockData,
|
||||
ExerciseBlockData,
|
||||
HomeworkBlockData,
|
||||
ImportBlockData,
|
||||
KeyPointBlockData,
|
||||
LessonPlan,
|
||||
LessonPlanDocument,
|
||||
NewTeachingBlockData,
|
||||
ObjectiveBlockData,
|
||||
ReflectionBlockData,
|
||||
RichTextBlockData,
|
||||
SummaryBlockData,
|
||||
TextStudyBlockData,
|
||||
TextbookContentNode,
|
||||
} from "../types";
|
||||
|
||||
/** 导出版本 */
|
||||
export type ExportVariant = "detailed" | "concise";
|
||||
|
||||
/** 简洁版包含的 Block 类型 */
|
||||
const CONCISE_BLOCK_TYPES = new Set([
|
||||
"objective",
|
||||
"new_teaching",
|
||||
"exercise",
|
||||
"homework",
|
||||
]);
|
||||
|
||||
/** 扁平化后的教学环节 */
|
||||
export interface PrintableSection {
|
||||
type: string;
|
||||
title: string;
|
||||
/** 已扁平化为字符串数组的内容 */
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
/** 导出元信息(页眉/页脚用) */
|
||||
export interface ExportMeta {
|
||||
planTitle: string;
|
||||
textbookTitle?: string;
|
||||
chapterTitle?: string;
|
||||
teacherName?: string;
|
||||
className?: string;
|
||||
/** 备课最后保存时间 ISO */
|
||||
lastSavedAt?: string;
|
||||
/** 教学时长(分钟),来自 import 节点 durationMin 求和 */
|
||||
totalDurationMin: number;
|
||||
}
|
||||
|
||||
/** 导出文档 */
|
||||
export interface PrintableLessonPlan {
|
||||
meta: ExportMeta;
|
||||
/** 课文正文(如有 textbook_content 节点) */
|
||||
textbookContent: string | null;
|
||||
/** 教学环节列表(按 order 排序) */
|
||||
sections: PrintableSection[];
|
||||
variant: ExportVariant;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将画布式文档扁平化为可打印的线性结构。
|
||||
*
|
||||
* @param plan 课案对象
|
||||
* @param meta 元信息(教师名、班级等由调用方注入)
|
||||
* @param variant detailed | concise
|
||||
*/
|
||||
export function flattenLessonPlanForPrint(
|
||||
plan: LessonPlan,
|
||||
meta: Partial<ExportMeta>,
|
||||
variant: ExportVariant = "detailed",
|
||||
): PrintableLessonPlan {
|
||||
const doc: LessonPlanDocument = plan.content;
|
||||
const textbookContent = extractTextbookContent(doc);
|
||||
const teachingNodes = doc.nodes
|
||||
.filter((n) => n.type !== "textbook_content")
|
||||
.filter((n) => variant === "detailed" || CONCISE_BLOCK_TYPES.has(n.type))
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
const sections = teachingNodes.map((node) =>
|
||||
flattenBlock(node.type, node.title, node.data as BlockData),
|
||||
);
|
||||
|
||||
// V5-4:教学时长由 import 节点求和
|
||||
const totalDurationMin = doc.nodes
|
||||
.filter((n) => n.type === "import")
|
||||
.reduce((sum, n) => {
|
||||
const data = n.data as ImportBlockData;
|
||||
return sum + (data.durationMin ?? 0);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
meta: {
|
||||
planTitle: plan.title,
|
||||
lastSavedAt: plan.lastSavedAt ?? undefined,
|
||||
totalDurationMin,
|
||||
...meta,
|
||||
},
|
||||
textbookContent,
|
||||
sections,
|
||||
variant,
|
||||
};
|
||||
}
|
||||
|
||||
function extractTextbookContent(doc: LessonPlanDocument): string | null {
|
||||
const node = doc.nodes.find(
|
||||
(n): n is TextbookContentNode => n.type === "textbook_content",
|
||||
);
|
||||
if (!node) return null;
|
||||
return node.data.content || null;
|
||||
}
|
||||
|
||||
function flattenBlock(
|
||||
type: string,
|
||||
title: string,
|
||||
data: BlockData,
|
||||
): PrintableSection {
|
||||
const lines = flattenBlockData(type, data);
|
||||
return { type, title, lines };
|
||||
}
|
||||
|
||||
function flattenBlockData(type: string, data: BlockData): string[] {
|
||||
switch (type) {
|
||||
case "objective":
|
||||
return flattenObjective(data as ObjectiveBlockData);
|
||||
case "key_point":
|
||||
return flattenKeyPoint(data as KeyPointBlockData);
|
||||
case "import":
|
||||
return flattenImport(data as ImportBlockData);
|
||||
case "new_teaching":
|
||||
return flattenNewTeaching(data as NewTeachingBlockData);
|
||||
case "summary":
|
||||
return flattenSummary(data as SummaryBlockData);
|
||||
case "homework":
|
||||
return flattenHomework(data as HomeworkBlockData);
|
||||
case "blackboard":
|
||||
return flattenBlackboard(data as BlackboardBlockData);
|
||||
case "reflection":
|
||||
return flattenReflection(data as ReflectionBlockData);
|
||||
case "exercise":
|
||||
return flattenExercise(data as ExerciseBlockData);
|
||||
case "text_study":
|
||||
return flattenTextStudy(data as TextStudyBlockData);
|
||||
case "rich_text":
|
||||
return flattenRichText(data as RichTextBlockData);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function flattenObjective(data: ObjectiveBlockData): string[] {
|
||||
const dimensionLabel: Record<ObjectiveItem["dimension"], string> = {
|
||||
knowledge: "知识与技能",
|
||||
process: "过程与方法",
|
||||
emotion: "情感态度",
|
||||
};
|
||||
return data.objectives.map(
|
||||
(o) => `[${dimensionLabel[o.dimension]}] ${o.text}`,
|
||||
);
|
||||
}
|
||||
|
||||
function flattenKeyPoint(data: KeyPointBlockData): string[] {
|
||||
return data.keyPoints.map((kp) =>
|
||||
kp.type === "key" ? `[重点] ${kp.text}` : `[难点] ${kp.text}`,
|
||||
);
|
||||
}
|
||||
|
||||
function flattenImport(data: ImportBlockData): string[] {
|
||||
const methodLabel: Record<ImportBlockData["method"], string> = {
|
||||
question: "提问导入",
|
||||
situation: "情境导入",
|
||||
review: "复习导入",
|
||||
other: "其他",
|
||||
};
|
||||
return [
|
||||
`方式:${methodLabel[data.method]}`,
|
||||
`时长:${data.durationMin} 分钟`,
|
||||
data.prompt ? `导入语:${data.prompt}` : "",
|
||||
].filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
function flattenNewTeaching(data: NewTeachingBlockData): string[] {
|
||||
const lines: string[] = [];
|
||||
data.teachingPoints.forEach((p, i) => {
|
||||
lines.push(`步骤 ${i + 1}:`);
|
||||
if (p.outline) lines.push(` 提纲:${p.outline}`);
|
||||
if (p.boardNotes) lines.push(` 板书要点:${p.boardNotes}`);
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
function flattenSummary(data: SummaryBlockData): string[] {
|
||||
const lines = data.summaryPoints.map((p, i) => `${i + 1}. ${p}`);
|
||||
if (data.homeworkPreview) lines.push(`作业预览:${data.homeworkPreview}`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function flattenHomework(data: HomeworkBlockData): string[] {
|
||||
const typeLabel: Record<HomeworkAssignment["type"], string> = {
|
||||
exercise: "练习",
|
||||
reading: "阅读",
|
||||
writing: "写作",
|
||||
};
|
||||
return data.assignments.map(
|
||||
(a) => `[${typeLabel[a.type]}] ${a.description}`,
|
||||
);
|
||||
}
|
||||
|
||||
function flattenBlackboard(data: BlackboardBlockData): string[] {
|
||||
const layoutLabel: Record<BlackboardBlockData["layout"], string> = {
|
||||
structure: "结构式",
|
||||
mindmap: "思维导图",
|
||||
text: "文字式",
|
||||
};
|
||||
return [`形式:${layoutLabel[data.layout]}`, data.content].filter(
|
||||
(s) => s.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function flattenReflection(data: ReflectionBlockData): string[] {
|
||||
const aspectLabel: Record<ReflectionItem["aspect"], string> = {
|
||||
effectiveness: "教学效果",
|
||||
problems: "存在问题",
|
||||
improvements: "改进措施",
|
||||
};
|
||||
return data.reflection.map(
|
||||
(r) => `[${aspectLabel[r.aspect]}] ${r.text}`,
|
||||
);
|
||||
}
|
||||
|
||||
function flattenExercise(data: ExerciseBlockData): string[] {
|
||||
if (data.items.length === 0) return ["(无题目)"];
|
||||
return data.items.map((item, i) => {
|
||||
const source = item.source === "inline" ? "课案内新建" : "题库";
|
||||
return `${i + 1}. [${source}] 题目 ID: ${item.questionId} (${item.score} 分)`;
|
||||
});
|
||||
}
|
||||
|
||||
function flattenTextStudy(data: TextStudyBlockData): string[] {
|
||||
if (data.annotations.length === 0) return ["(无文本研习标注)"];
|
||||
return data.annotations.map(
|
||||
(a, i) => `${i + 1}. [${a.title}] ${a.note}`,
|
||||
);
|
||||
}
|
||||
|
||||
function flattenRichText(data: RichTextBlockData): string[] {
|
||||
// HTML 简易去标签,仅保留文本(打印友好)
|
||||
const text = data.html
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.trim();
|
||||
return text.length > 0 ? [text] : [];
|
||||
}
|
||||
|
||||
// 仅用于类型推导的本地导入别名,避免在 switch case 中重复 import
|
||||
type ObjectiveItem = ObjectiveBlockData["objectives"][number];
|
||||
type HomeworkAssignment = HomeworkBlockData["assignments"][number];
|
||||
type ReflectionItem = ReflectionBlockData["reflection"][number];
|
||||
127
src/modules/lesson-preparation/lib/version-diff.ts
Normal file
127
src/modules/lesson-preparation/lib/version-diff.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* V5-16 T2:版本对比工具(反思闭环支撑)。
|
||||
*
|
||||
* 对比两个 LessonPlanDocument,输出节点级别的差异:
|
||||
* - added: 新版本中新增的节点
|
||||
* - removed: 旧版本中有但新版本中删除的节点
|
||||
* - modified: 两版本都有但内容(title/data/stage/differentiation)变化的节点
|
||||
* - unchanged: 相同的节点
|
||||
*
|
||||
* 纯函数,无副作用,便于单测。
|
||||
*/
|
||||
import type { LessonPlanDocument, LessonPlanNode } from "../types";
|
||||
|
||||
/** 差异类型 */
|
||||
export type DiffChangeType = "added" | "removed" | "modified" | "unchanged";
|
||||
|
||||
/** 单个节点的差异 */
|
||||
export interface NodeDiff {
|
||||
type: DiffChangeType;
|
||||
/** 新版本中的节点(added/modified/unchanged 时存在) */
|
||||
newNode?: LessonPlanNode;
|
||||
/** 旧版本中的节点(removed/modified/unchanged 时存在) */
|
||||
oldNode?: LessonPlanNode;
|
||||
/** modified 时的字段级变更列表 */
|
||||
changedFields?: string[];
|
||||
}
|
||||
|
||||
/** 文档对比结果 */
|
||||
export interface VersionDiffResult {
|
||||
diffs: NodeDiff[];
|
||||
/** 统计 */
|
||||
summary: {
|
||||
added: number;
|
||||
removed: number;
|
||||
modified: number;
|
||||
unchanged: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** 安全序列化节点数据用于比较(忽略 position 等非内容字段) */
|
||||
function nodeContentKey(n: LessonPlanNode): string {
|
||||
return JSON.stringify({
|
||||
title: n.title,
|
||||
data: n.data,
|
||||
stage: n.stage,
|
||||
differentiation: n.differentiation,
|
||||
type: n.type,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 对比两个课案文档,返回节点级差异。
|
||||
*
|
||||
* @param oldDoc 旧版本文档
|
||||
* @param newDoc 新版本文档
|
||||
*/
|
||||
export function diffDocuments(
|
||||
oldDoc: LessonPlanDocument,
|
||||
newDoc: LessonPlanDocument,
|
||||
): VersionDiffResult {
|
||||
const oldNodes = new Map<string, LessonPlanNode>();
|
||||
const newNodes = new Map<string, LessonPlanNode>();
|
||||
|
||||
for (const n of oldDoc.nodes) {
|
||||
if (n.type !== "textbook_content") oldNodes.set(n.id, n);
|
||||
}
|
||||
for (const n of newDoc.nodes) {
|
||||
if (n.type !== "textbook_content") newNodes.set(n.id, n);
|
||||
}
|
||||
|
||||
const diffs: NodeDiff[] = [];
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
let modified = 0;
|
||||
let unchanged = 0;
|
||||
|
||||
// 遍历新版本节点
|
||||
for (const [id, newNode] of newNodes) {
|
||||
const oldNode = oldNodes.get(id);
|
||||
if (!oldNode) {
|
||||
diffs.push({ type: "added", newNode });
|
||||
added++;
|
||||
} else {
|
||||
const oldKey = nodeContentKey(oldNode);
|
||||
const newKey = nodeContentKey(newNode);
|
||||
if (oldKey === newKey) {
|
||||
diffs.push({ type: "unchanged", oldNode, newNode });
|
||||
unchanged++;
|
||||
} else {
|
||||
const changedFields: string[] = [];
|
||||
if (oldNode.title !== newNode.title) changedFields.push("title");
|
||||
if (oldNode.stage !== newNode.stage) changedFields.push("stage");
|
||||
if (oldNode.differentiation !== newNode.differentiation) changedFields.push("differentiation");
|
||||
if (JSON.stringify(oldNode.data) !== JSON.stringify(newNode.data)) changedFields.push("data");
|
||||
diffs.push({ type: "modified", oldNode, newNode, changedFields });
|
||||
modified++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 遍历旧版本中已删除的节点
|
||||
for (const [id, oldNode] of oldNodes) {
|
||||
if (!newNodes.has(id)) {
|
||||
diffs.push({ type: "removed", oldNode });
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
// 排序:added/removed/modified 优先,unchanged 靠后
|
||||
const order: Record<DiffChangeType, number> = {
|
||||
removed: 0,
|
||||
added: 1,
|
||||
modified: 2,
|
||||
unchanged: 3,
|
||||
};
|
||||
diffs.sort((a, b) => order[a.type] - order[b.type]);
|
||||
|
||||
return {
|
||||
diffs,
|
||||
summary: { added, removed, modified, unchanged },
|
||||
};
|
||||
}
|
||||
|
||||
/** 便捷函数:是否有实际变更 */
|
||||
export function hasChanges(result: VersionDiffResult): boolean {
|
||||
return result.summary.added > 0 || result.summary.removed > 0 || result.summary.modified > 0;
|
||||
}
|
||||
Reference in New Issue
Block a user