V2-1: 12 个 Server Action 通过 getTranslations 翻译错误消息;Service/DataAccess 层抛出错误码异常(PublishServiceError/LessonPlanDataError),Actions 层通过 PUBLISH_ERROR_KEY_MAP 翻译为 i18n 消息 V2-2: SYSTEM_TEMPLATES name/title 改为 i18n 键,createLessonPlan 接受 translateTitle 函数在服务端翻译后存储到 DB V2-3: 8 处 as unknown as 断言替换为显式类型映射函数(mapRowToLessonPlan/mapRowToListItem/mapRowToTemplate/mapRowToVersion)+ 类型守卫(isLessonPlanStatus/isTemplateType/isTemplateScope) V2-4: MiniMap nodeColor 复用 lib/node-summary.ts 的 getNodeColor V2-5: a11y 深度修复 — lesson-plan-filters/exercise-block/inline-question-editor 的 select 添加 label htmlFor 关联;exercise-block 题目列表改为 ul/li;node-editor 画布添加 role=application + 键盘导航配置 V2-6: Tracker 埋点接入 — 新增 useLessonPlanTrackerSafe hook,在 create/save/publish/revert/duplicate/archive 6 处调用 tracker.track 同步更新架构图 004 和 005 文档
412 lines
12 KiB
TypeScript
412 lines
12 KiB
TypeScript
import "server-only";
|
||
|
||
import { cache } from "react";
|
||
import { and, desc, eq, inArray, like, or, sql, type SQL } from "drizzle-orm";
|
||
import { createId } from "@paralleldrive/cuid2";
|
||
|
||
import { db } from "@/shared/db";
|
||
import {
|
||
lessonPlans,
|
||
lessonPlanTemplates,
|
||
textbooks,
|
||
chapters,
|
||
subjects,
|
||
grades,
|
||
users,
|
||
} from "@/shared/db/schema";
|
||
import type { DataScope } from "@/shared/types/permissions";
|
||
import { SYSTEM_TEMPLATES } from "./constants";
|
||
import {
|
||
migrateV1ToV2,
|
||
normalizeDocument,
|
||
buildInitialContent,
|
||
} from "./lib/document-migration";
|
||
import type {
|
||
LessonPlan,
|
||
LessonPlanDocument,
|
||
LessonPlanListItem,
|
||
LessonPlanTemplate,
|
||
LessonPlanStatus,
|
||
TemplateType,
|
||
TemplateScope,
|
||
} from "./types";
|
||
|
||
// re-export 纯函数保持向后兼容
|
||
export { migrateV1ToV2, normalizeDocument, buildInitialContent };
|
||
|
||
// ---- data-access 层错误码(由 actions 层翻译为 i18n 消息)----
|
||
export class LessonPlanDataError extends Error {
|
||
constructor(public readonly code: "NOT_FOUND" | "TEMPLATE_NOT_FOUND") {
|
||
super(code);
|
||
this.name = "LessonPlanDataError";
|
||
}
|
||
}
|
||
|
||
// ---- 类型守卫:安全地将 DB string 收窄为联合类型 ----
|
||
const LESSON_PLAN_STATUSES = ["draft", "published", "archived"] as const;
|
||
function isLessonPlanStatus(v: string): v is LessonPlanStatus {
|
||
return (LESSON_PLAN_STATUSES as readonly string[]).includes(v);
|
||
}
|
||
const TEMPLATE_TYPES = ["system", "personal"] as const;
|
||
function isTemplateType(v: string): v is TemplateType {
|
||
return (TEMPLATE_TYPES as readonly string[]).includes(v);
|
||
}
|
||
const TEMPLATE_SCOPES = ["regular", "review", "experiment", "inquiry", "blank", "custom"] as const;
|
||
function isTemplateScope(v: string): v is TemplateScope {
|
||
return (TEMPLATE_SCOPES as readonly string[]).includes(v);
|
||
}
|
||
|
||
// ---- 类型映射:Drizzle 行 → LessonPlan(Date → ISO string)----
|
||
function mapRowToLessonPlan(row: {
|
||
id: string;
|
||
title: string;
|
||
textbookId: string | null;
|
||
chapterId: string | null;
|
||
coursePlanItemId: string | null;
|
||
subjectId: string | null;
|
||
gradeId: string | null;
|
||
templateId: string | null;
|
||
templateName: string | null;
|
||
content: unknown;
|
||
status: string;
|
||
creatorId: string;
|
||
lastSavedAt: Date | null;
|
||
createdAt: Date;
|
||
updatedAt: Date;
|
||
}): LessonPlan {
|
||
return {
|
||
id: row.id,
|
||
title: row.title,
|
||
textbookId: row.textbookId,
|
||
chapterId: row.chapterId,
|
||
coursePlanItemId: row.coursePlanItemId,
|
||
subjectId: row.subjectId,
|
||
gradeId: row.gradeId,
|
||
templateId: row.templateId,
|
||
templateName: row.templateName,
|
||
content: normalizeDocument(row.content),
|
||
status: isLessonPlanStatus(row.status) ? row.status : "draft",
|
||
creatorId: row.creatorId,
|
||
lastSavedAt: row.lastSavedAt?.toISOString() ?? null,
|
||
createdAt: row.createdAt.toISOString(),
|
||
updatedAt: row.updatedAt.toISOString(),
|
||
};
|
||
}
|
||
|
||
// ---- 类型映射:Drizzle 行 → LessonPlanListItem ----
|
||
function mapRowToListItem(row: {
|
||
id: string;
|
||
title: string;
|
||
textbookId: string | null;
|
||
chapterId: string | null;
|
||
coursePlanItemId: string | null;
|
||
subjectId: string | null;
|
||
gradeId: string | null;
|
||
templateId: string | null;
|
||
templateName: string | null;
|
||
content: unknown;
|
||
status: string;
|
||
creatorId: string;
|
||
lastSavedAt: Date | null;
|
||
createdAt: Date;
|
||
updatedAt: Date;
|
||
textbookTitle: string | null;
|
||
chapterTitle: string | null;
|
||
subjectName: string | null;
|
||
gradeName: string | null;
|
||
creatorName: string | null;
|
||
}): LessonPlanListItem {
|
||
return {
|
||
...mapRowToLessonPlan(row),
|
||
textbookTitle: row.textbookTitle,
|
||
chapterTitle: row.chapterTitle,
|
||
subjectName: row.subjectName,
|
||
gradeName: row.gradeName,
|
||
creatorName: row.creatorName,
|
||
};
|
||
}
|
||
|
||
// ---- 类型映射:Drizzle 行 → LessonPlanTemplate ----
|
||
function mapRowToTemplate(row: {
|
||
id: string;
|
||
name: string;
|
||
type: string;
|
||
scope: string;
|
||
blocks: unknown;
|
||
creatorId: string | null;
|
||
createdAt: Date;
|
||
updatedAt: Date;
|
||
}): LessonPlanTemplate {
|
||
return {
|
||
id: row.id,
|
||
name: row.name,
|
||
type: isTemplateType(row.type) ? row.type : "personal",
|
||
scope: isTemplateScope(row.scope) ? row.scope : "custom",
|
||
// 从 unknown 转换为 TemplateBlockSkeleton[](DB JSON 字段)
|
||
blocks: row.blocks as LessonPlanTemplate["blocks"],
|
||
creatorId: row.creatorId,
|
||
createdAt: row.createdAt.toISOString(),
|
||
updatedAt: row.updatedAt.toISOString(),
|
||
};
|
||
}
|
||
|
||
// ---- DataScope → 查询条件 ----
|
||
// P0-3 修复:按 scope 类型精确过滤,避免教师越权查看全校 published 课案
|
||
function buildScopeCondition(scope: DataScope, userId: string): SQL[] {
|
||
switch (scope.type) {
|
||
case "all":
|
||
return [];
|
||
case "owned":
|
||
return [eq(lessonPlans.creatorId, userId)];
|
||
case "class_taught": {
|
||
// 教师:自己创建的 + published 且属于自己教授学科的
|
||
const own = eq(lessonPlans.creatorId, userId);
|
||
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
|
||
const subjectFilter =
|
||
scope.subjectIds && scope.subjectIds.length > 0
|
||
? inArray(lessonPlans.subjectId, scope.subjectIds)
|
||
: sql<boolean>`true`;
|
||
return [
|
||
or(
|
||
own,
|
||
and(publishedFilter, subjectFilter),
|
||
)!,
|
||
];
|
||
}
|
||
case "grade_managed": {
|
||
// 教研组长/年级主任:自己创建的 + published 且属于自己管理的年级
|
||
const own = eq(lessonPlans.creatorId, userId);
|
||
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
|
||
const gradeFilter =
|
||
scope.gradeIds.length > 0
|
||
? inArray(lessonPlans.gradeId, scope.gradeIds)
|
||
: sql<boolean>`false`;
|
||
return [
|
||
or(
|
||
own,
|
||
and(publishedFilter, gradeFilter),
|
||
)!,
|
||
];
|
||
}
|
||
case "class_members": {
|
||
// 学生:仅查看 published 课案(需配合班级-课案关联表进一步收紧)
|
||
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
|
||
return [publishedFilter];
|
||
}
|
||
case "children": {
|
||
// 家长:仅查看 published 课案(同学生)
|
||
const publishedFilter = sql<boolean>`(${lessonPlans.status} = 'published')`;
|
||
return [publishedFilter];
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- 课案列表 ----
|
||
export const getLessonPlans = cache(
|
||
async (
|
||
params: {
|
||
query?: string;
|
||
textbookId?: string;
|
||
chapterId?: string;
|
||
subjectId?: string;
|
||
status?: string;
|
||
},
|
||
scope: DataScope,
|
||
userId: string,
|
||
): Promise<LessonPlanListItem[]> => {
|
||
const conditions: SQL[] = [
|
||
sql`${lessonPlans.status} != 'archived'`,
|
||
];
|
||
conditions.push(...buildScopeCondition(scope, userId));
|
||
|
||
if (params.query) {
|
||
conditions.push(like(lessonPlans.title, `%${params.query}%`));
|
||
}
|
||
if (params.textbookId)
|
||
conditions.push(eq(lessonPlans.textbookId, params.textbookId));
|
||
if (params.chapterId)
|
||
conditions.push(eq(lessonPlans.chapterId, params.chapterId));
|
||
if (params.subjectId)
|
||
conditions.push(eq(lessonPlans.subjectId, params.subjectId));
|
||
if (params.status) conditions.push(eq(lessonPlans.status, params.status));
|
||
|
||
const rows = await db
|
||
.select({
|
||
id: lessonPlans.id,
|
||
title: lessonPlans.title,
|
||
textbookId: lessonPlans.textbookId,
|
||
chapterId: lessonPlans.chapterId,
|
||
coursePlanItemId: lessonPlans.coursePlanItemId,
|
||
subjectId: lessonPlans.subjectId,
|
||
gradeId: lessonPlans.gradeId,
|
||
templateId: lessonPlans.templateId,
|
||
templateName: lessonPlans.templateName,
|
||
content: lessonPlans.content,
|
||
status: lessonPlans.status,
|
||
creatorId: lessonPlans.creatorId,
|
||
lastSavedAt: lessonPlans.lastSavedAt,
|
||
createdAt: lessonPlans.createdAt,
|
||
updatedAt: lessonPlans.updatedAt,
|
||
textbookTitle: textbooks.title,
|
||
chapterTitle: chapters.title,
|
||
subjectName: subjects.name,
|
||
gradeName: grades.name,
|
||
creatorName: users.name,
|
||
})
|
||
.from(lessonPlans)
|
||
.leftJoin(textbooks, eq(lessonPlans.textbookId, textbooks.id))
|
||
.leftJoin(chapters, eq(lessonPlans.chapterId, chapters.id))
|
||
.leftJoin(subjects, eq(lessonPlans.subjectId, subjects.id))
|
||
.leftJoin(grades, eq(lessonPlans.gradeId, grades.id))
|
||
.leftJoin(users, eq(lessonPlans.creatorId, users.id))
|
||
.where(and(...conditions))
|
||
.orderBy(desc(lessonPlans.updatedAt));
|
||
|
||
const items = rows.map(mapRowToListItem);
|
||
return items;
|
||
},
|
||
);
|
||
|
||
// ---- 单课案 ----
|
||
export const getLessonPlanById = cache(
|
||
async (id: string, userId: string): Promise<LessonPlan | null> => {
|
||
const rows = await db
|
||
.select()
|
||
.from(lessonPlans)
|
||
.where(eq(lessonPlans.id, id))
|
||
.limit(1);
|
||
if (rows.length === 0) return null;
|
||
const row = rows[0];
|
||
// 权限:creator 可看 draft;非 creator 仅 published
|
||
if (row.creatorId !== userId && row.status !== "published") return null;
|
||
return mapRowToLessonPlan(row);
|
||
},
|
||
);
|
||
|
||
// ---- 创建 ----
|
||
// V2-2 修复:接受 translateTitle 函数,将 SYSTEM_TEMPLATES 中的 i18n 键翻译为实际文本
|
||
export async function createLessonPlan(input: {
|
||
title: string;
|
||
textbookId?: string;
|
||
chapterId?: string;
|
||
subjectId?: string;
|
||
gradeId?: string;
|
||
templateId: string;
|
||
creatorId: string;
|
||
translateTitle?: (key: string) => string;
|
||
}): Promise<{ planId: string }> {
|
||
const template = await getTemplateById(input.templateId);
|
||
if (!template) throw new LessonPlanDataError("TEMPLATE_NOT_FOUND");
|
||
|
||
const planId = createId();
|
||
// 如果提供了翻译函数,将模板中的 i18n 键翻译为实际文本
|
||
const blocks = input.translateTitle
|
||
? template.blocks.map((b) => ({
|
||
...b,
|
||
title: input.translateTitle!(b.title),
|
||
}))
|
||
: template.blocks;
|
||
const content = buildInitialContent(blocks);
|
||
|
||
await db.insert(lessonPlans).values({
|
||
id: planId,
|
||
title: input.title,
|
||
textbookId: input.textbookId ?? null,
|
||
chapterId: input.chapterId ?? null,
|
||
subjectId: input.subjectId ?? null,
|
||
gradeId: input.gradeId ?? null,
|
||
templateId: template.id,
|
||
templateName: template.name,
|
||
content,
|
||
status: "draft",
|
||
creatorId: input.creatorId,
|
||
lastSavedAt: new Date(),
|
||
});
|
||
|
||
return { planId };
|
||
}
|
||
|
||
// ---- 更新 content(自动保存,不生成版本)----
|
||
export async function updateLessonPlanContent(
|
||
planId: string,
|
||
userId: string,
|
||
patch: { title?: string; content: LessonPlanDocument },
|
||
): Promise<void> {
|
||
await db
|
||
.update(lessonPlans)
|
||
.set({
|
||
...(patch.title ? { title: patch.title } : {}),
|
||
content: patch.content,
|
||
lastSavedAt: new Date(),
|
||
})
|
||
.where(
|
||
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
|
||
);
|
||
}
|
||
|
||
// ---- 软删除 ----
|
||
export async function softDeleteLessonPlan(
|
||
planId: string,
|
||
userId: string,
|
||
): Promise<void> {
|
||
await db
|
||
.update(lessonPlans)
|
||
.set({ status: "archived" })
|
||
.where(
|
||
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
|
||
);
|
||
}
|
||
|
||
// ---- 复制 ----
|
||
export async function duplicateLessonPlan(
|
||
planId: string,
|
||
userId: string,
|
||
): Promise<{ newPlanId: string }> {
|
||
const src = await getLessonPlanById(planId, userId);
|
||
if (!src) throw new LessonPlanDataError("NOT_FOUND");
|
||
|
||
const newId = createId();
|
||
await db.insert(lessonPlans).values({
|
||
id: newId,
|
||
title: `${src.title} - 副本`,
|
||
textbookId: src.textbookId,
|
||
chapterId: src.chapterId,
|
||
subjectId: src.subjectId,
|
||
gradeId: src.gradeId,
|
||
templateId: src.templateId,
|
||
templateName: src.templateName,
|
||
content: src.content,
|
||
status: "draft",
|
||
creatorId: userId,
|
||
lastSavedAt: new Date(),
|
||
});
|
||
return { newPlanId: newId };
|
||
}
|
||
|
||
// ---- 模板查询(内部)----
|
||
export async function getTemplateById(
|
||
templateId: string,
|
||
): Promise<LessonPlanTemplate | null> {
|
||
// 先查 system 固定模板
|
||
const sysDef = SYSTEM_TEMPLATES.find((t) => t.id === templateId);
|
||
if (sysDef) {
|
||
return {
|
||
id: sysDef.id,
|
||
name: sysDef.name,
|
||
type: "system",
|
||
scope: sysDef.scope,
|
||
blocks: sysDef.blocks,
|
||
creatorId: null,
|
||
createdAt: "",
|
||
updatedAt: "",
|
||
};
|
||
}
|
||
// 再查 DB(personal 模板)
|
||
const rows = await db
|
||
.select()
|
||
.from(lessonPlanTemplates)
|
||
.where(eq(lessonPlanTemplates.id, templateId))
|
||
.limit(1);
|
||
return rows.length > 0 ? mapRowToTemplate(rows[0]) : null;
|
||
}
|