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

@@ -0,0 +1,264 @@
/**
* M5 形成性评价闭环 - 数据访问层
*
* publish 课案时嵌入互动组件poll/quiz/exit_ticket
* 学生课中作答后结果回写到 lessonPlanFormativeResponses 表,
* 教师可查看实时反馈,闭环到课案优化。
*/
import "server-only";
import { db } from "@/shared/db";
import {
lessonPlanFormativeItems,
lessonPlanFormativeResponses,
} from "@/shared/db/schema";
import { eq, desc, asc } from "drizzle-orm";
import type { FormativeInteractionType } from "./lib/type-guards";
import { isFormativeInteractionType } from "./lib/type-guards";
/** 互动组件项 */
export interface FormativeItem {
id: string;
planId: string;
blockId: string;
interactionType: FormativeInteractionType;
payload: unknown;
instantFeedback: boolean;
orderIndex: number;
createdAt: Date;
updatedAt: Date;
}
/** 学生作答记录 */
export interface FormativeResponse {
id: string;
itemId: string;
studentId: string;
classId?: string;
response: unknown;
isCorrect?: boolean;
durationSec?: number;
createdAt: Date;
}
/**
* 查询课案的所有互动组件
*/
export async function getFormativeItemsByPlanId(
planId: string,
): Promise<FormativeItem[]> {
const rows = await db
.select()
.from(lessonPlanFormativeItems)
.where(eq(lessonPlanFormativeItems.planId, planId))
.orderBy(asc(lessonPlanFormativeItems.orderIndex));
return rows.map(mapRowToItem);
}
/**
* 查询单个互动组件
*/
export async function getFormativeItemById(
id: string,
): Promise<FormativeItem | null> {
const rows = await db
.select()
.from(lessonPlanFormativeItems)
.where(eq(lessonPlanFormativeItems.id, id))
.limit(1);
return rows.length === 0 ? null : mapRowToItem(rows[0]!);
}
/**
* 创建互动组件
*/
export async function createFormativeItem(
input: {
planId: string;
blockId: string;
interactionType: FormativeInteractionType;
payload: unknown;
instantFeedback?: boolean;
orderIndex?: number;
},
): Promise<FormativeItem> {
const [row] = await db.insert(lessonPlanFormativeItems).values({
planId: input.planId,
blockId: input.blockId,
interactionType: input.interactionType,
payload: input.payload,
instantFeedback: input.instantFeedback ?? false,
orderIndex: input.orderIndex ?? 0,
});
const insertedId = row.insertId;
const created = await getFormativeItemById(String(insertedId));
if (!created) throw new Error("FORMATIVE_ITEM_CREATE_FAILED");
return created;
}
/**
* 更新互动组件
*/
export async function updateFormativeItem(
id: string,
patch: {
payload?: unknown;
instantFeedback?: boolean;
orderIndex?: number;
},
): Promise<void> {
await db
.update(lessonPlanFormativeItems)
.set({
...(patch.payload !== undefined ? { payload: patch.payload } : {}),
...(patch.instantFeedback !== undefined ? { instantFeedback: patch.instantFeedback } : {}),
...(patch.orderIndex !== undefined ? { orderIndex: patch.orderIndex } : {}),
})
.where(eq(lessonPlanFormativeItems.id, id));
}
/**
* 删除互动组件
*/
export async function deleteFormativeItem(id: string): Promise<void> {
await db
.delete(lessonPlanFormativeItems)
.where(eq(lessonPlanFormativeItems.id, id));
}
/**
* 提交学生作答
*/
export async function submitFormativeResponse(
input: {
itemId: string;
studentId: string;
classId?: string;
response: unknown;
isCorrect?: boolean;
durationSec?: number;
},
): Promise<FormativeResponse> {
const [row] = await db.insert(lessonPlanFormativeResponses).values({
itemId: input.itemId,
studentId: input.studentId,
classId: input.classId,
response: input.response,
isCorrect: input.isCorrect,
durationSec: input.durationSec,
});
const insertedId = row.insertId;
const items = await getResponsesByItemId(input.itemId);
const created = items.find((r) => r.id === String(insertedId));
if (!created) throw new Error("FORMATIVE_RESPONSE_CREATE_FAILED");
return created;
}
/**
* 查询互动组件的所有作答
*/
export async function getResponsesByItemId(
itemId: string,
): Promise<FormativeResponse[]> {
const rows = await db
.select()
.from(lessonPlanFormativeResponses)
.where(eq(lessonPlanFormativeResponses.itemId, itemId))
.orderBy(desc(lessonPlanFormativeResponses.createdAt));
return rows.map(mapRowToResponse);
}
/**
* 查询某学生作答历史
*/
export async function getResponsesByStudentId(
studentId: string,
planId?: string,
): Promise<FormativeResponse[]> {
// 简化查询:按 studentId 查询,可选按 planId 过滤
if (planId) {
const items = await db
.select({ id: lessonPlanFormativeItems.id })
.from(lessonPlanFormativeItems)
.where(eq(lessonPlanFormativeItems.planId, planId));
if (items.length === 0) return [];
const itemIds = items.map((i) => i.id);
const rows = await db
.select()
.from(lessonPlanFormativeResponses)
.where(eq(lessonPlanFormativeResponses.studentId, studentId));
return rows.filter((r) => itemIds.includes(r.itemId)).map(mapRowToResponse);
}
const rows = await db
.select()
.from(lessonPlanFormativeResponses)
.where(eq(lessonPlanFormativeResponses.studentId, studentId));
return rows.map(mapRowToResponse);
}
/**
* 统计互动组件的作答情况(用于教师查看实时反馈)
*/
export async function getFormativeItemStats(
itemId: string,
): Promise<{ total: number; correct: number; incorrect: number; avgDurationSec: number }> {
const rows = await db
.select()
.from(lessonPlanFormativeResponses)
.where(eq(lessonPlanFormativeResponses.itemId, itemId));
const total = rows.length;
let correct = 0;
let incorrect = 0;
let totalDuration = 0;
let durationCount = 0;
for (const r of rows) {
if (r.isCorrect === true) correct++;
else if (r.isCorrect === false) incorrect++;
if (r.durationSec !== null && r.durationSec !== undefined) {
totalDuration += r.durationSec;
durationCount++;
}
}
return {
total,
correct,
incorrect,
avgDurationSec: durationCount > 0 ? Math.round(totalDuration / durationCount) : 0,
};
}
function mapRowToItem(
row: typeof lessonPlanFormativeItems.$inferSelect,
): FormativeItem {
const interactionType = isFormativeInteractionType(row.interactionType)
? row.interactionType
: "poll";
return {
id: row.id,
planId: row.planId,
blockId: row.blockId,
interactionType,
payload: row.payload,
instantFeedback: row.instantFeedback,
orderIndex: row.orderIndex,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function mapRowToResponse(
row: typeof lessonPlanFormativeResponses.$inferSelect,
): FormativeResponse {
return {
id: row.id,
itemId: row.itemId,
studentId: row.studentId,
classId: row.classId ?? undefined,
response: row.response,
isCorrect: row.isCorrect ?? undefined,
durationSec: row.durationSec ?? undefined,
createdAt: row.createdAt,
};
}