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:
@@ -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) {
|
||||
|
||||
167
src/modules/lesson-preparation/lib/document-diff.ts
Normal file
167
src/modules/lesson-preparation/lib/document-diff.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
74
src/modules/lesson-preparation/lib/scope-check.ts
Normal file
74
src/modules/lesson-preparation/lib/scope-check.ts
Normal 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 自有且非 published,data-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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化函数:将 unknown(DB 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user