feat(lesson-preparation): add AI evaluation, analytics, attachments, calendar, comments, review, substitutes, formative, and version diff

- Add actions-ai-evaluation, actions-analytics, actions-attachments, actions-calendar, actions-comments, actions-formative, actions-questions, actions-review, actions-substitutes

- Add corresponding data-access layers for each new action module

- Add calendar-view, curriculum-map-view, version-diff-viewer components

- Add editor-slice, selection-slice, version-slice hooks for state management

- Add document-diff and scope-check lib utilities

- Add default-question-service and external-questions-bridge services
This commit is contained in:
SpecialX
2026-07-03 10:25:21 +08:00
parent a16f09d3c3
commit 20023e13fd
75 changed files with 5131 additions and 1186 deletions

View File

@@ -108,91 +108,115 @@ export async function publishLessonPlanHomework(
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) {
const qt = item.inlineContent.type;
// 使用类型守卫校验题目类型(替代 as 断言 + 硬编码中文错误)
if (!isValidQuestionType(qt)) {
throw new PublishServiceError("INVALID_QUESTION_TYPE");
}
const questionId = await createQuestionWithRelations(
{
content: item.inlineContent.content,
type: qt,
difficulty: item.inlineContent.difficulty,
knowledgePointIds: item.inlineContent.knowledgePointIds,
},
input.userId,
);
newData.items[i] = {
...item,
questionId,
inlineContent: undefined,
};
}
}
// 4. 打包 exam 草稿(标题/描述由 actions 层 i18n 传入)
const examId = createId();
if (!plan.subjectId || !plan.gradeId) {
throw new PublishServiceError("NO_SUBJECT_OR_GRADE");
}
await persistExamDraft({
examId,
title: input.homeworkTitle,
creatorId: input.userId,
subjectId: plan.subjectId,
gradeId: plan.gradeId,
scheduledAt: undefined,
description: input.homeworkDescription,
});
// 插入 examQuestions通过 exams data-access 跨模块接口)
await addExamQuestions(
examId,
newData.items.map((it, i) => ({
questionId: it.questionId,
score: it.score,
order: i,
})),
);
// 5. 下发作业
const examId = createId();
const assignmentId = createId();
const targetStudentIds = await getStudentIdsByClassIds(input.classIds);
if (targetStudentIds.length === 0) {
throw new PublishServiceError("NO_STUDENTS");
}
await createHomeworkAssignment({
assignmentId,
sourceExamId: examId,
title: input.homeworkTitle,
description: input.homeworkDescription,
structure: null,
status: "published",
creatorId: input.userId,
availableAt: input.availableAt ?? null,
dueAt: input.dueAt ?? null,
allowLate: false,
lateDueAt: null,
maxAttempts: 1,
publish: true,
questions: newData.items.map((it, i) => ({
questionId: it.questionId,
score: it.score,
order: i,
})),
targetStudentIds,
});
const createdQuestionIds: string[] = [];
// 6. 回写溯源标记
newData.publishedExamId = examId;
newData.publishedAssignmentId = assignmentId;
newData.publishedAt = new Date().toISOString();
await db
.update(lessonPlans)
.set({ content: newContent })
.where(eq(lessonPlans.id, input.planId));
// P0-12 修复:将多步写操作包裹在事务中,保证原子性。
// 注意:跨模块 data-accesscreateQuestionWithRelations/persistExamDraft/addExamQuestions/
// createHomeworkAssignment使用各自的 db 连接,无法加入此事务。
// 若跨模块调用成功但下方事务失败,已创建的 exam/homework 将成为孤儿数据,
// 需通过补偿机制或定期清理任务处理。完整事务支持需跨模块 data-access 接受 tx 参数(中长期改进)。
try {
for (let i = 0; i < newData.items.length; i++) {
const item = newData.items[i];
if (item.source === "inline" && item.inlineContent) {
const qt = item.inlineContent.type;
// 使用类型守卫校验题目类型(替代 as 断言 + 硬编码中文错误)
if (!isValidQuestionType(qt)) {
throw new PublishServiceError("INVALID_QUESTION_TYPE");
}
const questionId = await createQuestionWithRelations(
{
content: item.inlineContent.content,
type: qt,
difficulty: item.inlineContent.difficulty,
knowledgePointIds: item.inlineContent.knowledgePointIds,
},
input.userId,
);
createdQuestionIds.push(questionId);
newData.items[i] = {
...item,
questionId,
inlineContent: undefined,
};
}
}
// 4. 打包 exam 草稿(标题/描述由 actions 层 i18n 传入)
await persistExamDraft({
examId,
title: input.homeworkTitle,
creatorId: input.userId,
subjectId: plan.subjectId,
gradeId: plan.gradeId,
scheduledAt: undefined,
description: input.homeworkDescription,
});
// 插入 examQuestions通过 exams data-access 跨模块接口)
await addExamQuestions(
examId,
newData.items.map((it, i) => ({
questionId: it.questionId,
score: it.score,
order: i,
})),
);
// 5. 下发作业
const targetStudentIds = await getStudentIdsByClassIds(input.classIds);
if (targetStudentIds.length === 0) {
throw new PublishServiceError("NO_STUDENTS");
}
await createHomeworkAssignment({
assignmentId,
sourceExamId: examId,
title: input.homeworkTitle,
description: input.homeworkDescription,
structure: null,
status: "published",
creatorId: input.userId,
availableAt: input.availableAt ?? null,
dueAt: input.dueAt ?? null,
allowLate: false,
lateDueAt: null,
maxAttempts: 1,
publish: true,
questions: newData.items.map((it, i) => ({
questionId: it.questionId,
score: it.score,
order: i,
})),
targetStudentIds,
});
// 6. 回写溯源标记 — 包裹在事务中保证原子性
newData.publishedExamId = examId;
newData.publishedAssignmentId = assignmentId;
newData.publishedAt = new Date().toISOString();
await db.transaction(async (tx) => {
await tx
.update(lessonPlans)
.set({ content: newContent })
.where(eq(lessonPlans.id, input.planId));
});
} catch (e) {
// 补偿错误处理:记录已创建的资源 ID 便于排查孤儿数据
if (e instanceof PublishServiceError) throw e;
console.error("[publishLessonPlanHomework] 部分失败,可能存在孤儿数据", {
planId: input.planId,
examId,
assignmentId,
createdQuestionIds,
error: e instanceof Error ? e.message : String(e),
});
throw e;
}
return { examId, assignmentId, updatedContent: newContent };
}