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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user