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

@@ -10,7 +10,7 @@ import { persistExamDraft, addExamQuestions } from "@/modules/exams/data-access"
import { createHomeworkAssignment } from "@/modules/homework/data-access-write";
import { getStudentIdsByClassIds } from "@/modules/classes/data-access";
import { normalizeDocument } from "./data-access";
import type { LessonPlanDocument, ExerciseBlockData } from "./types";
import type { LessonPlanDocument, ExerciseBlockData, LessonPlan, LessonPlanStatus } from "./types";
interface PublishInput {
planId: string;
@@ -27,6 +27,32 @@ interface PublishResult {
updatedContent: LessonPlanDocument;
}
// 类型守卫:安全地将 string 收窄为 LessonPlanStatus
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);
}
/**
* publish-service 错误:使用错误码替代硬编码中文,
* 由 actions 层通过 PUBLISH_ERROR_KEY_MAP 翻译为 i18n 消息。
*/
export type PublishErrorCode =
| "PLAN_NOT_FOUND"
| "NO_PERMISSION"
| "NO_EXERCISE_BLOCK"
| "NO_QUESTIONS"
| "ALREADY_PUBLISHED"
| "NO_SUBJECT_OR_GRADE"
| "NO_STUDENTS";
export class PublishServiceError extends Error {
constructor(public readonly code: PublishErrorCode) {
super(code);
this.name = "PublishServiceError";
}
}
export async function publishLessonPlanHomework(
input: PublishInput,
): Promise<PublishResult> {
@@ -36,38 +62,43 @@ export async function publishLessonPlanHomework(
.from(lessonPlans)
.where(eq(lessonPlans.id, input.planId))
.limit(1);
if (rows.length === 0) throw new Error("课案不存在");
const row = rows[0] as unknown as {
id: string;
content: unknown;
creatorId: string;
title: string;
textbookId: string | null;
chapterId: string | null;
subjectId: string | null;
gradeId: string | null;
};
const plan = {
...row,
if (rows.length === 0) throw new PublishServiceError("PLAN_NOT_FOUND");
const row = rows[0];
// 类型守卫:从 Drizzle 推导类型收窄为 LessonPlan 所需字段
const plan: LessonPlan = {
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(),
};
if (plan.creatorId !== input.userId)
throw new Error("无权发布");
throw new PublishServiceError("NO_PERMISSION");
// 2. 定位 exercise block
const block = plan.content.nodes.find((b) => b.id === input.blockId);
if (!block || block.type !== "exercise")
throw new Error("练习块不存在");
throw new PublishServiceError("NO_EXERCISE_BLOCK");
const data = block.data as ExerciseBlockData;
if (data.items.length === 0) throw new Error("练习块无题目");
if (data.items.length === 0) throw new PublishServiceError("NO_QUESTIONS");
if (data.publishedAssignmentId)
throw new Error("该练习块已发布,请使用'重新发布'");
throw new PublishServiceError("ALREADY_PUBLISHED");
// 3. inline 题目入库,替换占位 ID
const newContent: LessonPlanDocument = structuredClone(plan.content);
const newBlock = newContent.nodes.find((b) => b.id === input.blockId);
if (!newBlock || newBlock.type !== "exercise")
throw new Error("练习块不存在");
throw new PublishServiceError("NO_EXERCISE_BLOCK");
const newData = newBlock.data as ExerciseBlockData;
for (let i = 0; i < newData.items.length; i++) {
@@ -100,7 +131,7 @@ export async function publishLessonPlanHomework(
// 4. 打包 exam 草稿
const examId = createId();
if (!plan.subjectId || !plan.gradeId) {
throw new Error("课案缺少学科或年级信息,无法发布");
throw new PublishServiceError("NO_SUBJECT_OR_GRADE");
}
await persistExamDraft({
examId,
@@ -125,7 +156,7 @@ export async function publishLessonPlanHomework(
const assignmentId = createId();
const targetStudentIds = await getStudentIdsByClassIds(input.classIds);
if (targetStudentIds.length === 0) {
throw new Error("所选班级无学生");
throw new PublishServiceError("NO_STUDENTS");
}
await createHomeworkAssignment({
assignmentId,