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:
@@ -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 行 → 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[] {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user