refactor(lesson-preparation): V2 审计深度修复 — Server Actions i18n + 错误码模式 + 类型断言清零 + a11y 深度修复 + Tracker 埋点接入

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 文档
This commit is contained in:
SpecialX
2026-06-22 18:45:35 +08:00
parent 1fe30984b6
commit 97e59b95a1
23 changed files with 668 additions and 135 deletions

View File

@@ -26,11 +26,130 @@ import type {
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 行 → LessonPlanDate → 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[] {
@@ -143,10 +262,7 @@ export const getLessonPlans = cache(
.where(and(...conditions))
.orderBy(desc(lessonPlans.updatedAt));
const items = rows as unknown as LessonPlanListItem[];
items.forEach((it) => {
it.content = normalizeDocument(it.content);
});
const items = rows.map(mapRowToListItem);
return items;
},
);
@@ -163,13 +279,12 @@ export const getLessonPlanById = cache(
const row = rows[0];
// 权限creator 可看 draft非 creator 仅 published
if (row.creatorId !== userId && row.status !== "published") return null;
const plan = row as unknown as LessonPlan;
plan.content = normalizeDocument(plan.content);
return plan;
return mapRowToLessonPlan(row);
},
);
// ---- 创建 ----
// V2-2 修复:接受 translateTitle 函数,将 SYSTEM_TEMPLATES 中的 i18n 键翻译为实际文本
export async function createLessonPlan(input: {
title: string;
textbookId?: string;
@@ -178,12 +293,20 @@ export async function createLessonPlan(input: {
gradeId?: string;
templateId: string;
creatorId: string;
translateTitle?: (key: string) => string;
}): Promise<{ planId: string }> {
const template = await getTemplateById(input.templateId);
if (!template) throw new Error("模板不存在");
if (!template) throw new LessonPlanDataError("TEMPLATE_NOT_FOUND");
const planId = createId();
const content = buildInitialContent(template.blocks);
// 如果提供了翻译函数,将模板中的 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,
@@ -240,7 +363,7 @@ export async function duplicateLessonPlan(
userId: string,
): Promise<{ newPlanId: string }> {
const src = await getLessonPlanById(planId, userId);
if (!src) throw new Error("课案不存在或无权访问");
if (!src) throw new LessonPlanDataError("NOT_FOUND");
const newId = createId();
await db.insert(lessonPlans).values({
@@ -284,7 +407,5 @@ export async function getTemplateById(
.from(lessonPlanTemplates)
.where(eq(lessonPlanTemplates.id, templateId))
.limit(1);
return rows.length > 0
? (rows[0] as unknown as LessonPlanTemplate)
: null;
return rows.length > 0 ? mapRowToTemplate(rows[0]) : null;
}