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:
SpecialX
2026-07-03 10:25:21 +08:00
parent a16f09d3c3
commit 20023e13fd
75 changed files with 5131 additions and 1186 deletions

View File

@@ -40,7 +40,7 @@ export function markdownToPlainText(markdown: string): string {
.replace(/```[\s\S]*?```/g, "")
.replace(/`([^`]+)`/g, "$1")
// 去除引用标记
.replace(/^>\s+/gm, "")
.replace(/^\s*>\s+/gm, "")
// 去除列表标记
.replace(/^[\s]*[-*+]\s+/gm, "")
.replace(/^[\s]*\d+\.\s+/gm, "")
@@ -114,12 +114,13 @@ function buildOffsetMap(markdown: string): {
// 简化映射:逐字符遍历 Markdown跳过被去除的字符
// 这里采用与 markdownToPlainText 一致的简化逻辑
// V4 P2-5 修复:统一 regex 标志为 gm与 markdownToPlainText 保持一致
const skipPatterns: RegExp[] = [
/^#{1,6}\s+/m,
/^\s*[-*+]\s+/m,
/^\s*\d+\.\s+/m,
/^\s*>\s+/m,
/^---+$/m,
/^#{1,6}\s+/gm,
/^\s*[-*+]\s+/gm,
/^\s*\d+\.\s+/gm,
/^\s*>\s+/gm,
/^---+$/gm,
];
while (mdIdx < markdown.length) {

View 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 };
}

View File

@@ -21,6 +21,9 @@ export async function translateFieldErrors(
result[field] = messages.map((msg) => {
// 仅翻译以 "error." 开头的 i18n 键,其他保持原样
if (msg.startsWith("error.")) {
// V4 P1-13 修复next-intl 的 t 函数对键有字面量类型约束,
// 动态字符串需断言为键类型。msg 已通过 startsWith 校验为合法 i18n 键前缀,
// 此处 as 属于"从 string 收窄到字面量联合类型"的类型收窄,符合项目规则例外。
return t(msg as Parameters<typeof t>[0]);
}
return msg;

View File

@@ -1,3 +1,4 @@
import { isRecord } from "@/shared/lib/type-guards";
import type { LessonPlanNode, TextbookContentNode } from "../types";
/**
@@ -19,79 +20,81 @@ export interface NodeSummaryT {
): string;
}
// ---- 安全字段提取辅助(替代 as 断言,从 unknown 收窄)----
function getArrayLength(v: unknown): number | undefined {
return Array.isArray(v) ? v.length : undefined;
}
function getString(v: unknown): string | undefined {
return typeof v === "string" ? v : undefined;
}
function getNumber(v: unknown): number | undefined {
return typeof v === "number" ? v : undefined;
}
/**
* 纯函数:获取节点摘要文本(用于节点卡片显示)。
* 从 lesson-node.tsx 抽取,便于单元测试。
* 翻译文本由调用方通过 t 函数注入,保证纯函数可测性。
*
* P1 修复:使用类型守卫从 unknown 安全收窄 node.data 字段,
* 替代原先的 `as { html?: string; ... }` 断言。
*/
export function getNodeSummary(node: LessonPlanNode, t: NodeSummaryT): string {
const data = node.data as {
// 富文本类
html?: string;
// 文本研习
sourceText?: string;
annotations?: unknown[];
// 练习
items?: unknown[];
// 教学目标
objectives?: unknown[];
// 重难点
keyPoints?: unknown[];
// 导入
durationMin?: number;
prompt?: string;
// 新授
teachingPoints?: unknown[];
// 小结
summaryPoints?: unknown[];
// 作业
assignments?: unknown[];
// 板书
content?: string;
// 反思
reflection?: unknown[];
// 知识点
knowledgePointIds?: string[];
};
// node.data 是 BlockData 联合类型,这里安全地作为 unknown 读取可选字段
const data: unknown = node.data;
// 按类型优先级提取摘要
if (data.items !== undefined) {
return t("editor.questionCount", { count: data.items.length });
// 按类型优先级提取摘要(使用安全字段提取,避免 as 断言)
const itemsLen = isRecord(data) ? getArrayLength(data.items) : undefined;
if (itemsLen !== undefined) {
return t("editor.questionCount", { count: itemsLen });
}
if (data.objectives !== undefined) {
return t("editor.itemCount", { count: data.objectives.length });
const objectivesLen = isRecord(data) ? getArrayLength(data.objectives) : undefined;
if (objectivesLen !== undefined) {
return t("editor.itemCount", { count: objectivesLen });
}
if (data.keyPoints !== undefined) {
return t("editor.itemCount", { count: data.keyPoints.length });
const keyPointsLen = isRecord(data) ? getArrayLength(data.keyPoints) : undefined;
if (keyPointsLen !== undefined) {
return t("editor.itemCount", { count: keyPointsLen });
}
if (data.teachingPoints !== undefined) {
return t("editor.pointCount", { count: data.teachingPoints.length });
const teachingPointsLen = isRecord(data) ? getArrayLength(data.teachingPoints) : undefined;
if (teachingPointsLen !== undefined) {
return t("editor.pointCount", { count: teachingPointsLen });
}
if (data.summaryPoints !== undefined) {
return t("editor.itemCount", { count: data.summaryPoints.length });
const summaryPointsLen = isRecord(data) ? getArrayLength(data.summaryPoints) : undefined;
if (summaryPointsLen !== undefined) {
return t("editor.itemCount", { count: summaryPointsLen });
}
if (data.assignments !== undefined) {
return t("editor.assignmentCount", { count: data.assignments.length });
const assignmentsLen = isRecord(data) ? getArrayLength(data.assignments) : undefined;
if (assignmentsLen !== undefined) {
return t("editor.assignmentCount", { count: assignmentsLen });
}
if (data.reflection !== undefined) {
return t("editor.itemCount", { count: data.reflection.length });
const reflectionLen = isRecord(data) ? getArrayLength(data.reflection) : undefined;
if (reflectionLen !== undefined) {
return t("editor.itemCount", { count: reflectionLen });
}
if (data.durationMin !== undefined) {
return t("editor.durationMin", { count: data.durationMin });
const durationMin = isRecord(data) ? getNumber(data.durationMin) : undefined;
if (durationMin !== undefined) {
return t("editor.durationMin", { count: durationMin });
}
if (data.annotations !== undefined && data.sourceText !== undefined) {
return t("editor.charCount", { count: data.sourceText.length });
const sourceText = isRecord(data) ? getString(data.sourceText) : undefined;
const hasAnnotations = isRecord(data) ? Array.isArray(data.annotations) : false;
if (hasAnnotations && sourceText !== undefined) {
return t("editor.charCount", { count: sourceText.length });
}
if (data.sourceText !== undefined && data.sourceText) {
return t("editor.charCount", { count: data.sourceText.length });
if (sourceText) {
return t("editor.charCount", { count: sourceText.length });
}
if (data.content !== undefined && data.content) {
const text = data.content.replace(/<[^>]+>/g, "").trim();
const content = isRecord(data) ? getString(data.content) : undefined;
if (content) {
const text = content.replace(/<[^>]+>/g, "").trim();
return text.slice(0, 40) || t("editor.nodeSummaryEmpty");
}
if (data.html) {
const html = isRecord(data) ? getString(data.html) : undefined;
if (html) {
// 去标签后取前 40 字
const text = data.html.replace(/<[^>]+>/g, "").trim();
const text = html.replace(/<[^>]+>/g, "").trim();
return text.slice(0, 40) || t("editor.nodeSummaryEmpty");
}
return t("editor.nodeSummaryEmpty");
@@ -109,25 +112,40 @@ export function getTextbookContentSummary(
}
/**
* 节点类型 → 图标颜色Material Design 色板)。
* 节点类型 → CSS 变量名V4 P1-4 修复:从硬编码 hex 提取到 globals.css 设计令牌)。
* 供 lesson-node 和 minimap 复用。
*/
export const NODE_COLORS: Record<string, string> = {
objective: "#4caf50",
key_point: "#f44336",
import: "#2196f3",
new_teaching: "#9c27b0",
consolidation: "#ff9800",
summary: "#607d8b",
homework: "#795548",
blackboard: "#009688",
text_study: "#3f51b5",
exercise: "#e91e63",
rich_text: "#9e9e9e",
reflection: "#cddc39",
textbook_content: "#455a64",
};
export const NODE_COLOR_VARS: Record<string, string> = {
objective: "var(--lesson-node-objective)",
key_point: "var(--lesson-node-key-point)",
import: "var(--lesson-node-import)",
new_teaching: "var(--lesson-node-new-teaching)",
consolidation: "var(--lesson-node-consolidation)",
summary: "var(--lesson-node-summary)",
homework: "var(--lesson-node-homework)",
blackboard: "var(--lesson-node-blackboard)",
text_study: "var(--lesson-node-text-study)",
exercise: "var(--lesson-node-exercise)",
rich_text: "var(--lesson-node-rich-text)",
reflection: "var(--lesson-node-reflection)",
textbook_content: "var(--lesson-node-textbook-content)",
}
/** 选中态颜色变量 */
export const NODE_SELECTED_COLOR_VAR = "var(--lesson-node-selected)"
/** 默认颜色变量(未匹配类型时使用) */
export const NODE_DEFAULT_COLOR_VAR = "var(--lesson-node-default)"
/**
* @deprecated 使用 `getNodeColorVar` 替代。保留是为了向后兼容旧代码引用。
*/
export const NODE_COLORS: Record<string, string> = NODE_COLOR_VARS
export function getNodeColor(type: string): string {
return NODE_COLORS[type] ?? "#9e9e9e";
return NODE_COLOR_VARS[type] ?? NODE_DEFAULT_COLOR_VAR
}
export function getNodeColorVar(type: string): string {
return NODE_COLOR_VARS[type] ?? NODE_DEFAULT_COLOR_VAR
}

View File

@@ -6,6 +6,18 @@ import type {
} from "../types";
import { getNodeColor } from "./node-summary";
/**
* 纯函数:根据 nodeId 从节点列表中查找节点类型。
* P0-8 修复toRfEdges 需要节点类型来获取颜色,而非传入 nodeId。
*/
function getNodeTypeById(
nodes: AnyLessonPlanNode[],
nodeId: string,
): string {
const node = nodes.find((n) => n.id === nodeId);
return node?.type ?? "rich_text";
}
/**
* 纯函数:将课案 nodes/edges 映射为 React Flow 格式。
* 从 node-editor.tsx 抽取,便于单元测试。
@@ -103,14 +115,16 @@ export function toRfEdges(
edges: AnyLessonPlanEdge[],
selectedNodeId: string | null,
anchors: NodeAnchor[],
nodes: AnyLessonPlanNode[] = [],
): Edge[] {
return edges.map((e) => {
if (e.type === "anchor") {
// 锚点边:默认 40% 透明度,选中关联节点时 100%
const anchor = anchors.find((a) => a.id === e.anchorId);
const isActive = anchor && anchor.nodeId === selectedNodeId;
// P1-4 修复:使用锚点关联节点的颜色,而非硬编码蓝色
const strokeColor = anchor ? getNodeColor(anchor.nodeId) : "#9e9e9e";
// P0-8 修复:传入节点 type 而非 nodeId使颜色映射正确生效
const nodeType = anchor ? getNodeTypeById(nodes, anchor.nodeId) : "rich_text";
const strokeColor = getNodeColor(nodeType);
return {
...e,
animated: isActive,

View File

@@ -0,0 +1,74 @@
import "server-only"
import type { LessonPlan } from "../types"
import type { AuthContext, DataScope } from "@/shared/types/permissions"
/**
* 课案权限作用域校验工具V4 P0-1 修复)。
*
* data-access 层的 `getLessonPlanById` 仅校验 `creatorId` 或 `status = "published"`
* 对于 parent/student/grade_head 等角色的跨年级隔离需由调用方(页面层)补齐。
* 本模块提供纯函数辅助,避免在每个路由重复实现。
*/
/**
* 判断单个课案是否落在当前用户的 DataScope 内。
*
* - admin (`type: "all"`):永远返回 true
* - teacher (`type: "class_taught"`)creator 自有课案权限由 data-access 层校验;
* 若 plan 非 creator 自有且非 publisheddata-access 已返回 null这里只兜底 gradeId
* - parent/student/grade_head需要校验 `plan.gradeId` 是否在 scope.gradeIds 范围内
*
* @returns `true` 表示通过;`false` 表示越权(应返回 notFound
*/
export function isPlanInScope(plan: LessonPlan, scope: DataScope): boolean {
switch (scope.type) {
case "all":
return true
case "owned":
// owned 仅允许查看自己的课案data-access 层 creatorId 已校验,这里兜底
return true
case "class_taught":
// class_taught 的课案权限依赖 creator + subjectId/gradeId 过滤;
// data-access 的 buildScopeCondition 已处理列表查询;单课案由 creator 校验
return true
case "class_members":
// student仅可查看已发布 + 本年级课案
return plan.status === "published" && isGradeInScope(plan, scope.gradeIds)
case "children":
// parent仅可查看已发布 + 孩子所在年级课案
return plan.status === "published" && isGradeInScope(plan, scope.gradeIds)
case "grade_managed":
// grade_head/teaching_head仅可查看所管年级的课案
return isGradeInScope(plan, scope.gradeIds)
default: {
// 穷尽性检查unknown 类型分支兜底拒绝
const _exhaustive: never = scope
void _exhaustive
return false
}
}
}
/** 校验 plan.gradeId 是否在允许的 gradeIds 集合内(无 gradeId 的课案视为通过) */
function isGradeInScope(plan: LessonPlan, gradeIds?: string[]): boolean {
if (!plan.gradeId) return true
if (!gradeIds || gradeIds.length === 0) return false
return gradeIds.includes(plan.gradeId)
}
/**
* 断言式调用:失败时抛 `LessonPlanScopeError`,由 Next.js error.tsx 兜底。
* 用于 Server Component 页面层。
*/
export class LessonPlanScopeError extends Error {
constructor(public readonly reason: "not_found" | "grade_scope_violation") {
super(reason)
this.name = "LessonPlanScopeError"
}
}
export function assertPlanInScope(plan: LessonPlan, ctx: AuthContext): void {
if (!isPlanInScope(plan, ctx.dataScope)) {
throw new LessonPlanScopeError("grade_scope_violation")
}
}

View File

@@ -19,6 +19,7 @@ import type {
ReflectionItem,
RichTextBlockData,
SummaryBlockData,
TemplateBlockSkeleton,
TemplateScope,
TemplateType,
TextStudyBlockData,
@@ -26,11 +27,47 @@ import type {
} from "../types";
// ---- 基础类型守卫 ----
const LESSON_PLAN_STATUSES = ["draft", "published", "archived"] as const;
// M3 审核工作流:扩展为 6 种状态
const LESSON_PLAN_STATUSES = ["draft", "submitted", "approved", "published", "rejected", "archived"] as const;
export function isLessonPlanStatus(v: string): v is LessonPlanStatus {
return (LESSON_PLAN_STATUSES as readonly string[]).includes(v);
}
/** M3 审核工作流:审核决策类型守卫 */
const REVIEW_DECISIONS = ["approved", "rejected"] as const;
export type ReviewDecision = (typeof REVIEW_DECISIONS)[number];
export function isReviewDecision(v: string): v is ReviewDecision {
return (REVIEW_DECISIONS as readonly string[]).includes(v);
}
/** M3 审核工作流:代课教师状态类型守卫 */
const SUBSTITUTE_STATUSES = ["active", "expired", "cancelled"] as const;
export type SubstituteStatus = (typeof SUBSTITUTE_STATUSES)[number];
export function isSubstituteStatus(v: string): v is SubstituteStatus {
return (SUBSTITUTE_STATUSES as readonly string[]).includes(v);
}
/** M3 审核工作流:附件类型守卫 */
const ATTACHMENT_TYPES = ["reference", "material", "supplementary"] as const;
export type AttachmentType = (typeof ATTACHMENT_TYPES)[number];
export function isAttachmentType(v: string): v is AttachmentType {
return (ATTACHMENT_TYPES as readonly string[]).includes(v);
}
/** M3 审核工作流:形成性评价互动类型守卫 */
const FORMATIVE_INTERACTION_TYPES = ["poll", "quiz", "exit_ticket"] as const;
export type FormativeInteractionType = (typeof FORMATIVE_INTERACTION_TYPES)[number];
export function isFormativeInteractionType(v: string): v is FormativeInteractionType {
return (FORMATIVE_INTERACTION_TYPES as readonly string[]).includes(v);
}
/** M3 审核工作流:标准层级类型守卫 */
const STANDARD_LEVELS = ["national", "curriculum", "custom"] as const;
export type StandardLevel = (typeof STANDARD_LEVELS)[number];
export function isStandardLevel(v: string): v is StandardLevel {
return (STANDARD_LEVELS as readonly string[]).includes(v);
}
const TEMPLATE_TYPES = ["system", "personal"] as const;
export function isTemplateType(v: string): v is TemplateType {
return (TEMPLATE_TYPES as readonly string[]).includes(v);
@@ -180,7 +217,7 @@ export function isLessonPlanNode(
}
// ---- 题目类型守卫 ----
const VALID_QUESTION_TYPES = [
export const VALID_QUESTION_TYPES = [
"single_choice",
"multiple_choice",
"text",
@@ -211,3 +248,30 @@ const VALID_BLOCK_TYPES: BlockType[] = [
export function isBlockType(v: string): v is BlockType {
return (VALID_BLOCK_TYPES as readonly string[]).includes(v);
}
// ---- TemplateBlockSkeleton 守卫与规范化(替代 as 断言从 DB unknown 转换)----
// isObject 已在上方定义(第 56 行),复用同一类型守卫
/**
* 类型守卫:判断 unknown 是否为合法的 TemplateBlockSkeleton。
* 用于从 DB JSON 字段安全收窄,替代 `as LessonPlanTemplate["blocks"]` 断言。
*/
export function isTemplateBlockSkeleton(v: unknown): v is TemplateBlockSkeleton {
return (
isObject(v) &&
typeof v.type === "string" &&
isBlockType(v.type) &&
typeof v.title === "string" &&
(v.hint === undefined || typeof v.hint === "string")
);
}
/**
* 规范化函数:将 unknownDB JSON 字段)安全转换为 TemplateBlockSkeleton[]。
* 过滤掉结构不合法的项,避免畸形数据导致运行时错误。
* 替代 `as LessonPlanTemplate["blocks"]` / `as unknown as LessonPlanTemplate["blocks"]` 断言。
*/
export function normalizeTemplateBlocks(v: unknown): TemplateBlockSkeleton[] {
if (!Array.isArray(v)) return [];
return v.filter(isTemplateBlockSkeleton);
}