feat(lesson-preparation): add readonly view, anchor node selector, and type guards

- Add lesson-plan-readonly-view for viewing published plans

- Add anchor-node-selector and textbook-segments for canvas anchor positioning

- Add i18n-errors and type-guards lib utilities

- Add lesson-plan-provider-setup for provider initialization

- Update actions, data-access (knowledge, versions, main), publish-service

- Update blocks (blackboard, exercise, homework, import, key-point, objective, reflection)

- Update editor, node-editor, node-edit-panel, pickers, and providers
This commit is contained in:
SpecialX
2026-06-24 12:02:42 +08:00
parent a48e7d0e27
commit 6bc113eaff
39 changed files with 2129 additions and 571 deletions

View File

@@ -10,7 +10,8 @@ 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, LessonPlan, LessonPlanStatus } from "./types";
import { isExerciseBlockData, isLessonPlanStatus, isValidQuestionType } from "./lib/type-guards";
import type { LessonPlanDocument, LessonPlan, LessonPlanStatus } from "./types";
interface PublishInput {
planId: string;
@@ -19,6 +20,10 @@ interface PublishInput {
classIds: string[];
availableAt?: Date;
dueAt?: Date;
/** 作业标题(由 actions 层 i18n 翻译后传入)*/
homeworkTitle: string;
/** 作业描述(由 actions 层 i18n 翻译后传入)*/
homeworkDescription: string;
}
interface PublishResult {
@@ -27,12 +32,6 @@ 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 消息。
@@ -44,7 +43,8 @@ export type PublishErrorCode =
| "NO_QUESTIONS"
| "ALREADY_PUBLISHED"
| "NO_SUBJECT_OR_GRADE"
| "NO_STUDENTS";
| "NO_STUDENTS"
| "INVALID_QUESTION_TYPE";
export class PublishServiceError extends Error {
constructor(public readonly code: PublishErrorCode) {
@@ -64,7 +64,10 @@ export async function publishLessonPlanHomework(
.limit(1);
if (rows.length === 0) throw new PublishServiceError("PLAN_NOT_FOUND");
const row = rows[0];
// 类型守卫:从 Drizzle 推导类型收窄为 LessonPlan 所需字段
// 使用类型守卫收窄(替代 as 断言)
const status: LessonPlanStatus = isLessonPlanStatus(row.status)
? row.status
: "draft";
const plan: LessonPlan = {
id: row.id,
title: row.title,
@@ -76,7 +79,7 @@ export async function publishLessonPlanHomework(
templateId: row.templateId,
templateName: row.templateName,
content: normalizeDocument(row.content),
status: isLessonPlanStatus(row.status) ? row.status : "draft",
status,
creatorId: row.creatorId,
lastSavedAt: row.lastSavedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
@@ -85,13 +88,15 @@ export async function publishLessonPlanHomework(
if (plan.creatorId !== input.userId)
throw new PublishServiceError("NO_PERMISSION");
// 2. 定位 exercise block
// 2. 定位 exercise block(使用类型守卫替代 as 断言)
const block = plan.content.nodes.find((b) => b.id === input.blockId);
if (!block || block.type !== "exercise")
throw new PublishServiceError("NO_EXERCISE_BLOCK");
const data = block.data as ExerciseBlockData;
if (data.items.length === 0) throw new PublishServiceError("NO_QUESTIONS");
if (data.publishedAssignmentId)
if (!isExerciseBlockData(block.data))
throw new PublishServiceError("NO_EXERCISE_BLOCK");
if (block.data.items.length === 0)
throw new PublishServiceError("NO_QUESTIONS");
if (block.data.publishedAssignmentId)
throw new PublishServiceError("ALREADY_PUBLISHED");
// 3. inline 题目入库,替换占位 ID
@@ -99,22 +104,22 @@ export async function publishLessonPlanHomework(
const newBlock = newContent.nodes.find((b) => b.id === input.blockId);
if (!newBlock || newBlock.type !== "exercise")
throw new PublishServiceError("NO_EXERCISE_BLOCK");
const newData = newBlock.data as ExerciseBlockData;
if (!isExerciseBlockData(newBlock.data))
throw new PublishServiceError("NO_EXERCISE_BLOCK");
const newData = newBlock.data;
for (let i = 0; i < newData.items.length; i++) {
const item = newData.items[i];
if (item.source === "inline" && item.inlineContent) {
// 类型守卫:确保 inline 题目类型合法
const validTypes = ["single_choice", "multiple_choice", "text", "judgment", "composite"] as const;
const qt = item.inlineContent.type;
if (!validTypes.includes(qt as typeof validTypes[number])) {
throw new Error(`无效的题目类型: ${qt}`);
// 使用类型守卫校验题目类型(替代 as 断言 + 硬编码中文错误)
if (!isValidQuestionType(qt)) {
throw new PublishServiceError("INVALID_QUESTION_TYPE");
}
const questionType = qt as typeof validTypes[number];
const questionId = await createQuestionWithRelations(
{
content: item.inlineContent.content,
type: questionType,
type: qt,
difficulty: item.inlineContent.difficulty,
knowledgePointIds: item.inlineContent.knowledgePointIds,
},
@@ -128,19 +133,19 @@ export async function publishLessonPlanHomework(
}
}
// 4. 打包 exam 草稿
// 4. 打包 exam 草稿(标题/描述由 actions 层 i18n 传入)
const examId = createId();
if (!plan.subjectId || !plan.gradeId) {
throw new PublishServiceError("NO_SUBJECT_OR_GRADE");
}
await persistExamDraft({
examId,
title: `${plan.title} - 作业`,
title: input.homeworkTitle,
creatorId: input.userId,
subjectId: plan.subjectId,
gradeId: plan.gradeId,
scheduledAt: undefined,
description: `来自课案:${plan.title}`,
description: input.homeworkDescription,
});
// 插入 examQuestions通过 exams data-access 跨模块接口)
await addExamQuestions(
@@ -161,8 +166,8 @@ export async function publishLessonPlanHomework(
await createHomeworkAssignment({
assignmentId,
sourceExamId: examId,
title: `${plan.title} - 作业`,
description: `来自课案:${plan.title}`,
title: input.homeworkTitle,
description: input.homeworkDescription,
structure: null,
status: "published",
creatorId: input.userId,